<?php

declare(strict_types=1);

namespace FecaMailshots\Application;

use FecaMailshots\Domain\AppError;

final class DslValidator
{
    private SourceMetadataProvider $metadata;

    /** @var array<string, list<string>> */
    private array $filterSourceRequirements = [
        'selected-renewal' => ['renewals'],
        'pending-renewal' => ['renewals'],
        'primary-contact' => ['contacts'],
        'fen1-contact' => ['contacts'],
        'selected' => ['advertisers'],
        'issue' => ['advertisers|ads|pages|articles|issues|invoices'],
        'pending-invoice' => ['invoices'],
        'selected-invoice' => ['invoices'],
        'invoice-ids' => ['invoices'],
        'member-or-affiliate-or-parish-council' => ['accounts'],
        'account-has-article-in-issue' => ['accounts'],
    ];

    public function __construct(SourceMetadataProvider $metadata)
    {
        $this->metadata = $metadata;
    }

    /**
     * @param array{sources:list<string>, where:array<int, mixed>} $ast
     * @return array{errors:list<string>,warnings:list<string>,expected_fields:list<string>}
     */
    public function validate(array $ast): array
    {
        $errors = [];
        $warnings = [];

        $sources = $ast['sources'];
        if ($sources === []) {
            $errors[] = 'At least one source must be provided.';
            return ['errors' => $errors, 'warnings' => $warnings, 'expected_fields' => []];
        }

        $hasBuiltIn = false;
        $hasCustom = false;
        foreach ($sources as $source) {
            if (!$this->metadata->sourceExists($source)) {
                $errors[] = 'Unknown source: ' . $source;
                continue;
            }
            if (str_contains($source, '.')) {
                $hasCustom = true;
            } else {
                $hasBuiltIn = true;
            }
        }

        if ($hasBuiltIn && $hasCustom) {
            $errors[] = 'Mixing built-in and custom sources is not supported in v1.5.';
        }

        $hasIssueContextFilter = $this->hasConstrainingIssueFilter($ast['where']);
        for ($i = 1; $i < count($sources); $i++) {
            if (!$this->hasPriorJoinPath(array_slice($sources, 0, $i), $sources[$i])
                && !$this->canAppendIssueContext($sources[$i], $hasIssueContextFilter)
            ) {
                $errors[] = sprintf('No approved join path to %s', $sources[$i]);
            }
        }

        $expectedFields = [];
        foreach ($sources as $source) {
            foreach ($this->metadata->sourceFields($source) as $field) {
                $expectedFields[] = $source . '.' . $field;
            }
        }

        if (!$this->hasAnyEmailField($sources)) {
            $warnings[] = 'Data Source has no obvious email field; select an explicit Recipient Email Field on each Mailshot.';
        }

        foreach ($ast['where'] as $predicate) {
            $this->validatePredicate($predicate, $sources, $errors);
        }

        return [
            'errors' => $errors,
            'warnings' => $warnings,
            'expected_fields' => $expectedFields,
        ];
    }

    /** @param list<string> $sources @param list<string> &$errors */
    private function validatePredicate(array $predicate, array $sources, array &$errors): void
    {
        if ($predicate['type'] === 'group') {
            foreach ($predicate['items'] as $item) {
                $this->validatePredicate($item, $sources, $errors);
            }
            return;
        }

        if ($predicate['type'] === 'filter') {
            $name = $predicate['name'];
            if (!isset($this->filterSourceRequirements[$name])) {
                $errors[] = 'Unknown filter: ' . $name;
                return;
            }
            foreach ($this->filterSourceRequirements[$name] as $requiredSource) {
                $alternatives = explode('|', $requiredSource);
                $hasRequiredSource = false;
                foreach ($alternatives as $alternative) {
                    if (in_array($alternative, $sources, true)) {
                        $hasRequiredSource = true;
                        break;
                    }
                }
                if (!$hasRequiredSource) {
                    $errors[] = sprintf('Filter %s requires source %s', $name, $requiredSource);
                }
            }
            $this->validateFilterArgs($predicate, $errors);
            return;
        }

        if (isset($predicate['lhs'])) {
            $this->validateFieldRef($predicate['lhs'], $sources, $errors);
        }
        if (isset($predicate['rhs']) && is_array($predicate['rhs']) && isset($predicate['rhs']['source'])) {
            $this->validateFieldRef($predicate['rhs'], $sources, $errors);
        }

        if ($predicate['type'] === 'comparison_field' && !in_array($predicate['op'], ['=', '!='], true)) {
            $errors[] = 'Field-to-field comparison supports only = and !=';
        }

        if ($predicate['type'] === 'comparison_field' && in_array($predicate['op'], ['contains', 'starts-with', 'ends-with'], true)) {
            $errors[] = $predicate['op'] . ' requires a literal right-hand value';
        }

        if ($predicate['type'] === 'comparison_value'
            && is_array($predicate['rhs'] ?? null)
            && ($predicate['rhs']['type'] ?? '') === 'blank_literal'
            && !in_array($predicate['op'], ['=', '!='], true)
        ) {
            $errors[] = 'Blank comparison supports only = and !=';
        }
    }

    /** @param list<string> &$errors */
    private function validateFilterArgs(array $predicate, array &$errors): void
    {
        $name = (string) ($predicate['name'] ?? '');
        $args = is_array($predicate['args'] ?? null) ? $predicate['args'] : [];
        if (in_array($name, ['selected-renewal', 'pending-renewal', 'primary-contact', 'fen1-contact', 'selected', 'pending-invoice', 'selected-invoice', 'member-or-affiliate-or-parish-council'], true)) {
            if ($args !== []) {
                $errors[] = sprintf('Filter %s does not take arguments', $name);
            }
            return;
        }
        if (in_array($name, ['issue', 'account-has-article-in-issue'], true)) {
            if (count($args) !== 1 || !is_int($args[0])) {
                $errors[] = sprintf('Filter %s requires exactly one numeric issue ID', $name);
            }
            return;
        }
        if ($name === 'invoice-ids') {
            if ($args === []) {
                $errors[] = 'Filter invoice-ids requires one or more numeric invoice IDs';
                return;
            }
            foreach ($args as $arg) {
                if (!is_int($arg)) {
                    $errors[] = 'Filter invoice-ids accepts numeric invoice IDs only';
                    return;
                }
            }
        }
    }

    /** @param array{source:string,field:string} $fieldRef @param list<string> $sources @param list<string> &$errors */
    private function validateFieldRef(array $fieldRef, array $sources, array &$errors): void
    {
        $source = $fieldRef['source'];
        if (!in_array($source, $sources, true)) {
            $errors[] = 'Field source is not in selected sources: ' . $source;
            return;
        }

        if (!$this->metadata->sourceExists($source)) {
            $errors[] = 'Unknown field source: ' . $source;
            return;
        }

        $fields = $this->metadata->sourceFields($source);
        if (!in_array($fieldRef['field'], $fields, true)) {
            $errors[] = sprintf('Unknown field %s on %s', $fieldRef['field'], $source);
        }
    }

    /** @param list<string> $sources */
    private function hasAnyEmailField(array $sources): bool
    {
        foreach ($sources as $source) {
            if ($this->metadata->hasEmailField($source)) {
                return true;
            }
        }
        return false;
    }

    private function canAppendIssueContext(string $rightSource, bool $hasIssueContextFilter): bool
    {
        return $rightSource === 'issues' && $hasIssueContextFilter;
    }

    /** @param list<string> $priorSources */
    private function hasPriorJoinPath(array $priorSources, string $rightSource): bool
    {
        for ($i = count($priorSources) - 1; $i >= 0; $i--) {
            if ($this->metadata->joinPath($priorSources[$i], $rightSource) !== null) {
                return true;
            }
        }
        return false;
    }

    /** @param list<array<string,mixed>> $predicates */
    private function hasConstrainingIssueFilter(array $predicates, bool $negated = false): bool
    {
        foreach ($predicates as $predicate) {
            $predicateNegated = $negated || !empty($predicate['not']);
            if (($predicate['type'] ?? '') === 'filter'
                && ($predicate['name'] ?? '') === 'issue'
                && !$predicateNegated
            ) {
                return true;
            }
            if (($predicate['type'] ?? '') === 'group'
                && $this->hasConstrainingIssueFilter(is_array($predicate['items'] ?? null) ? $predicate['items'] : [], $predicateNegated)
            ) {
                return true;
            }
        }
        return false;
    }
}
