<?php

declare(strict_types=1);

namespace Phan\Plugin;

use AssertionError;
use ast\Node;
use Closure;
use Phan\AST\Visitor\Element;
use Phan\CLI;
use Phan\CodeBase;
use Phan\Config;
use Phan\Exception\IssueException;
use Phan\Issue;
use Phan\IssueInstance;
use Phan\Language\Context;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\Func;
use Phan\Language\Element\FunctionInterface;
use Phan\Language\Element\Method;
use Phan\Language\Element\Property;
use Phan\Language\Element\TypedElement;
use Phan\Language\Element\UnaddressableTypedElement;
use Phan\Language\FQSEN;
use Phan\Language\Type;
use Phan\Language\UnionType;
use Phan\LanguageServer\CompletionRequest;
use Phan\LanguageServer\CompletionResolver;
use Phan\LanguageServer\DefinitionResolver;
use Phan\LanguageServer\GoToDefinitionRequest;
use Phan\Library\RAII;
use Phan\Plugin\Internal\ArrayReturnTypeOverridePlugin;
use Phan\Plugin\Internal\BaselineLoadingPlugin;
use Phan\Plugin\Internal\BaselineSavingPlugin;
use Phan\Plugin\Internal\BuiltinSuppressionPlugin;
use Phan\Plugin\Internal\CallableParamPlugin;
use Phan\Plugin\Internal\ClosureReturnTypeOverridePlugin;
use Phan\Plugin\Internal\CompactPlugin;
use Phan\Plugin\Internal\DependentReturnTypeOverridePlugin;
use Phan\Plugin\Internal\ExtendedDependentReturnTypeOverridePlugin;
use Phan\Plugin\Internal\IssueFixingPlugin\IssueFixer;
use Phan\Plugin\Internal\MiscParamPlugin;
use Phan\Plugin\Internal\NodeSelectionPlugin;
use Phan\Plugin\Internal\NodeSelectionVisitor;
use Phan\Plugin\Internal\RedundantConditionCallPlugin;
use Phan\Plugin\Internal\RequireExistsPlugin;
use Phan\Plugin\Internal\StringFunctionPlugin;
use Phan\Plugin\Internal\ThrowAnalyzerPlugin;
use Phan\Plugin\Internal\VariableTrackerPlugin;
use Phan\PluginV2\AfterAnalyzeFileCapability;
use Phan\PluginV2\AnalyzeClassCapability;
use Phan\PluginV2\AnalyzeFunctionCapability;
use Phan\PluginV2\AnalyzeMethodCapability;
use Phan\PluginV2\AnalyzePropertyCapability;
use Phan\PluginV2\BeforeAnalyzeCapability;
use Phan\PluginV2\BeforeAnalyzeFileCapability;
use Phan\PluginV2\FinalizeProcessCapability;
use Phan\PluginV2\HandleLazyLoadInternalFunctionCapability;
use Phan\PluginV3;
use Phan\PluginV3\AnalyzeFunctionCallCapability;
use Phan\PluginV3\AutomaticFixCapability;
use Phan\PluginV3\BeforeAnalyzePhaseCapability;
use Phan\PluginV3\PluginAwarePostAnalysisVisitor;
use Phan\PluginV3\PluginAwarePreAnalysisVisitor;
use Phan\PluginV3\PostAnalyzeNodeCapability;
use Phan\PluginV3\PreAnalyzeNodeCapability;
use Phan\PluginV3\ReturnTypeOverrideCapability;
use Phan\PluginV3\SubscribeEmitIssueCapability;
use Phan\PluginV3\SuppressionCapability;
use Phan\Suggestion;
use Throwable;
use UnusedSuppressionPlugin;

use function get_class;
use function is_null;
use function is_object;
use function property_exists;

use const EXIT_FAILURE;
use const PHP_EOL;
use const STDERR;

/**
 * The root plugin that calls out each hook
 * on any plugins defined in the configuration.
 *
 * (Note: This is called almost once per each AST node being analyzed.
 * Speed is preferred over using Phan\Memoize.)
 *
 * @phan-file-suppress PhanPluginDescriptionlessCommentOnPublicMethod TODO: Document
 */
final class ConfigPluginSet extends PluginV3 implements
    \Phan\PluginV3\AfterAnalyzeFileCapability,
    \Phan\PluginV3\AnalyzeClassCapability,
    \Phan\PluginV3\AnalyzeFunctionCapability,
    AnalyzeFunctionCallCapability,
    \Phan\PluginV3\AnalyzeMethodCapability,
    \Phan\PluginV3\AnalyzePropertyCapability,
    \Phan\PluginV3\BeforeAnalyzeCapability,
    BeforeAnalyzePhaseCapability,
    \Phan\PluginV3\BeforeAnalyzeFileCapability,
    \Phan\PluginV3\FinalizeProcessCapability,
    ReturnTypeOverrideCapability,
    SubscribeEmitIssueCapability,
    SuppressionCapability
{

    /** @var list<PluginV3>|null - Cached plugin set for this instance. Lazily generated. */
    private $plugin_set;

    /**
     * @var associative-array<int, Closure(CodeBase,Context,Node|int|string|float):void> - plugins to analyze nodes in pre-order
     */
    private $pre_analyze_node_plugin_set;

    /**
     * @var associative-array<int, Closure(CodeBase,Context,Node|int|string|float,list<Node>):void> - plugins to analyze nodes in post-order
     */
    private $post_analyze_node_plugin_set;

    /**
     * @var list<BeforeAnalyzeFileCapability> - plugins to analyze files before Phan's analysis of that file is completed.
     */
    private $before_analyze_file_plugin_set;

    /**
     * @var list<BeforeAnalyzeCapability> - plugins to analyze the project before Phan starts the analyze phase and before methods are analyzed.
     */
    private $before_analyze_plugin_set;

    /**
     * @var list<BeforeAnalyzePhaseCapability> - plugins to analyze the project before Phan starts the analyze phase and after methods are analyzed.
     */
    private $before_analyze_phase_plugin_set;

    /**
     * @var list<AfterAnalyzeFileCapability> - plugins to analyze files after Phan's analysis of that file is completed.
     */
    private $after_analyze_file_plugin_set;

    /** @var list<AnalyzeClassCapability>|null - plugins to analyze class declarations. */
    private $analyze_class_plugin_set;

    /** @var list<AnalyzeFunctionCallCapability>|null - plugins to analyze invocations of subsets of functions and methods. */
    private $analyze_function_call_plugin_set;

    /** @var list<AnalyzeFunctionCapability>|null - plugins to analyze function declarations. */
    private $analyze_function_plugin_set;

    /** @var list<AnalyzePropertyCapability>|null - plugins to analyze property declarations. */
    private $analyze_property_plugin_set;

    /** @var list<AnalyzeMethodCapability>|null - plugins to analyze method declarations.*/
    private $analyze_method_plugin_set;

    /** @var list<HandleLazyLoadInternalFunctionCapability>|null - plugins to modify Phan's information about internal Funcs when loaded for the first time */
    private $handle_lazy_load_internal_function_plugin_set;

    /** @var list<FinalizeProcessCapability>|null - plugins to call finalize() on after analysis is finished. */
    private $finalize_process_plugin_set;

    /** @var list<ReturnTypeOverrideCapability>|null - plugins which generate return UnionTypes of functions based on arguments. */
    private $return_type_override_plugin_set;

    /** @var list<SubscribeEmitIssueCapability>|null - plugins which get called on issues that aren't suppressed. */
    private $subscribe_emit_issue_plugin_set;

    /** @var list<SuppressionCapability>|null - plugins which can be used to suppress issues or inspect suppressions. */
    private $suppression_plugin_set;

    /** @var ?UnusedSuppressionPlugin - TODO: Refactor*/
    private $unused_suppression_plugin = null;

    /**
     * @var bool
     */
    private $did_analyze_phase_start = false;

    /**
     * Call `ConfigPluginSet::instance()` instead.
     */
    private function __construct()
    {
    }

    /**
     * @return ConfigPluginSet
     * A shared single instance of this plugin
     */
    public static function instance(): ConfigPluginSet
    {
        static $instance = null;
        if ($instance === null) {
            $instance = self::newInstance();
        }
        return $instance;
    }

    /**
     * Returns a brand-new ConfigPluginSet where all plugins are initialized.
     *
     * If one of the plugins could not be instantiated, this prints an error message and terminates the program.
     */
    private static function newInstance(): ConfigPluginSet
    {
        try {
            $instance = new self();
            $instance->ensurePluginsExist();
            return $instance;
        } catch (Throwable $e) {
            // An unexpected error.
            // E.g. a third party plugin class threw when building the list of return types to analyze.
            $message = \sprintf(
                "Failed to initialize plugins, exiting: %s: %s at %s:%d\nStack Trace:\n%s",
                get_class($e),
                $e->getMessage(),
                $e->getFile(),
                $e->getLine(),
                $e->getTraceAsString()
            );
            \error_log($message);
            exit(EXIT_FAILURE);
        }
    }

    /**
     * Resets this set of plugins to the state it had before any user-defined or internal plugins were added,
     * then re-initialize plugins based on the current configuration.
     *
     * @internal - Used only for testing
     */
    public static function reset(): void
    {
        $instance = self::instance();
        // Set all of the private properties to their uninitialized default values
        // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach this is intentionally iterating over private properties of the clone.
        foreach (new self() as $k => $v) {
            $instance->{$k} = $v;
        }
        $instance->ensurePluginsExist();
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the node exists
     *
     * @param Context $context
     * The context in which the node exits. This is
     * the context inside the given node rather than
     * the context outside of the given node
     *
     * @param Node $node
     * The php-ast Node being analyzed.
     */
    public function preAnalyzeNode(
        CodeBase $code_base,
        Context $context,
        Node $node
    ): void {
        $plugin_callback = $this->pre_analyze_node_plugin_set[$node->kind] ?? null;
        if ($plugin_callback !== null) {
            $plugin_callback(
                $code_base,
                $context,
                $node
            );
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the node exists
     *
     * @param Context $context
     * The context in which the node exits. This is
     * the context inside the given node rather than
     * the context outside of the given node
     *
     * @param Node $node
     * The php-ast Node being analyzed.
     *
     * @param list<Node> $parent_node_list
     * The parent node of the given node (if one exists).
     */
    public function postAnalyzeNode(
        CodeBase $code_base,
        Context $context,
        Node $node,
        array $parent_node_list = []
    ): void {
        $plugin_callback = $this->post_analyze_node_plugin_set[$node->kind] ?? null;
        if ($plugin_callback !== null) {
            $plugin_callback(
                $code_base,
                $context,
                $node,
                $parent_node_list
            );
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the node exists
     *
     * @param Context $context
     * A context with the file name for $file_contents and the scope before analyzing $node.
     *
     * @param string $file_contents
     * @param Node $node
     * @override
     */
    public function beforeAnalyzeFile(
        CodeBase $code_base,
        Context $context,
        string $file_contents,
        Node $node
    ): void {
        foreach ($this->before_analyze_file_plugin_set as $plugin) {
            $plugin->beforeAnalyzeFile(
                $code_base,
                $context,
                $file_contents,
                $node
            );
        }
    }

    /**
     * This method is called before analyzing a project and before analyzing methods.
     *
     * @param CodeBase $code_base
     * The code base in which the project exists
     *
     * @override
     */
    public function beforeAnalyze(CodeBase $code_base): void
    {
        $this->did_analyze_phase_start = true;
        foreach ($this->before_analyze_plugin_set as $plugin) {
            $plugin->beforeAnalyze($code_base);
        }
    }

    /**
     * This method is called before analyzing a project and after analyzing methods.
     *
     * @param CodeBase $code_base
     * The code base in which the project exists
     *
     * @override
     */
    public function beforeAnalyzePhase(CodeBase $code_base): void
    {
        $this->did_analyze_phase_start = true;
        foreach ($this->before_analyze_phase_plugin_set as $plugin) {
            $plugin->beforeAnalyzePhase($code_base);
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the node exists
     *
     * @param Context $context
     * A context with the file name for $file_contents and the scope after analyzing $node.
     *
     * @param string $file_contents
     * @param Node $node
     * @override
     */
    public function afterAnalyzeFile(
        CodeBase $code_base,
        Context $context,
        string $file_contents,
        Node $node
    ): void {
        foreach ($this->after_analyze_file_plugin_set as $plugin) {
            $plugin->afterAnalyzeFile(
                $code_base,
                $context,
                $file_contents,
                $node
            );
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the class exists
     *
     * @param Clazz $class
     * A class being analyzed
     * @override
     */
    public function analyzeClass(
        CodeBase $code_base,
        Clazz $class
    ): void {
        foreach ($this->analyze_class_plugin_set as $plugin) {
            $plugin->analyzeClass(
                $code_base,
                $class
            );
        }
        if ($this->hasAnalyzePropertyPlugins()) {
            foreach ($class->getPropertyMap($code_base) as $property) {
                $this->analyzeProperty($code_base, $property);
            }
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the method exists
     *
     * @param Method $method
     * A method being analyzed
     * @override
     */
    public function analyzeMethod(
        CodeBase $code_base,
        Method $method
    ): void {
        foreach ($this->analyze_method_plugin_set as $plugin) {
            $plugin->analyzeMethod(
                $code_base,
                $method
            );
        }
    }

    /**
     * This will be called if Phan's file and element-based suppressions did not suppress the issue.
     *
     * @param CodeBase $code_base
     *
     * @param Context $context context near where the issue occurred
     *
     * @param string $issue_type
     * The type of issue to emit such as Issue::ParentlessClass
     *
     * @param int $lineno
     * The line number where the issue was found
     *
     * @param list<string|int|float|bool|Type|UnionType|FQSEN|TypedElement|UnaddressableTypedElement> $parameters
     *
     * @param ?Suggestion $suggestion Phan's suggestion for how to fix the issue, if any.
     *
     * @return bool true if the given issue instance should be suppressed, given the current file contents.
     */
    public function shouldSuppressIssue(
        CodeBase $code_base,
        Context $context,
        string $issue_type,
        int $lineno,
        array $parameters,
        ?Suggestion $suggestion
    ): bool {
        foreach ($this->suppression_plugin_set as $plugin) {
            if ($plugin->shouldSuppressIssue(
                $code_base,
                $context,
                $issue_type,
                $lineno,
                $parameters,
                $suggestion
            )) {
                if ($this->unused_suppression_plugin) {
                    // @phan-suppress-next-line PhanAccessMethodInternal
                    $this->unused_suppression_plugin->recordPluginSuppression($plugin, $context->getFile(), $issue_type, $lineno);
                }
                return true;
            }
        }
        return false;
    }

    /**
     * @param CodeBase $code_base
     * @param string $file_path
     * @return array<string,list<int>> Maps 0 or more issue types to a *list* of lines that this plugin set is going to suppress.
     */
    public function getIssueSuppressionList(
        CodeBase $code_base,
        string $file_path
    ): array {
        $result = [];
        foreach ($this->suppression_plugin_set as $plugin) {
            $result += $plugin->getIssueSuppressionList(
                $code_base,
                $file_path
            );
        }
        return $result;
    }

    /**
     * @return list<SuppressionCapability>
     * @suppress PhanPossiblyNullTypeReturn should always be initialized before any issues get emitted.
     */
    public function getSuppressionPluginSet(): array
    {
        return $this->suppression_plugin_set;
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the function exists
     *
     * @param Func $function
     * A function being analyzed
     * @override
     */
    public function analyzeFunction(
        CodeBase $code_base,
        Func $function
    ): void {
        foreach ($this->analyze_function_plugin_set as $plugin) {
            $plugin->analyzeFunction(
                $code_base,
                $function
            );
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base in which the property exists
     *
     * @param Property $property
     * A property being analyzed
     *
     * (Called by analyzeClass())
     * @override
     */
    public function analyzeProperty(
        CodeBase $code_base,
        Property $property
    ): void {
        foreach ($this->analyze_property_plugin_set as $plugin) {
            try {
                $plugin->analyzeProperty(
                    $code_base,
                    $property
                );
            } catch (IssueException $exception) {
                // e.g. getUnionType() can throw, PropertyTypesAnalyzer is probably emitting duplicate issues
                Issue::maybeEmitInstance(
                    $code_base,
                    $property->getContext(),
                    $exception->getIssueInstance()
                );
                continue;
            }
        }
    }

    /**
     * @param CodeBase $code_base
     * The code base used for previous analysis steps
     * @override
     */
    public function finalizeProcess(
        CodeBase $code_base
    ): void {
        foreach ($this->finalize_process_plugin_set as $plugin) {
            $plugin->finalizeProcess($code_base);
        }
    }

    /**
     * Returns true if analyzeFunction() will execute any plugins.
     */
    public function hasAnalyzeFunctionPlugins(): bool
    {
        if (is_null($this->plugin_set)) {
            throw new AssertionError("Expected plugins to be loaded in " . __METHOD__);
        }
        // @phan-suppress-next-line PhanPossiblyNullTypeArgumentInternal
        return \count($this->analyze_function_plugin_set) > 0;
    }

    /**
     * Returns true if analyzeMethod() will execute any plugins.
     */
    public function hasAnalyzeMethodPlugins(): bool
    {
        if (is_null($this->plugin_set)) {
            throw new AssertionError("Expected plugins to be loaded in " . __METHOD__);
        }
        // @phan-suppress-next-line PhanPossiblyNullTypeArgumentInternal
        return \count($this->analyze_method_plugin_set) > 0;
    }

    /**
     * @param Closure(CodeBase, Context, FunctionInterface, list<Node|int|string|float>, ?Node):void $a
     * @param ?Closure(CodeBase, Context, FunctionInterface, list<Node|int|string|float>, ?Node):void $b
     * @return Closure(CodeBase, Context, FunctionInterface, list<Node|int|string|float>, ?Node):void $b
     */
    public static function mergeAnalyzeFunctionCallClosures(Closure $a, Closure $b = null): Closure
    {
        if (!$b) {
            return $a;
        }
        /**
         * @param list<Node|mixed> $args
         */
        return static function (CodeBase $code_base, Context $context, FunctionInterface $func, array $args, ?Node $node) use ($a, $b): void {
            $a($code_base, $context, $func, $args, $node);
            $b($code_base, $context, $func, $args, $node);
        };
    }
    /**
     * @param CodeBase $code_base
     * @return array<string,\Closure> maps FQSEN string to closure
     */
    public function getAnalyzeFunctionCallClosures(CodeBase $code_base): array
    {
        if (is_null($this->plugin_set)) {
            throw new AssertionError("Expected plugins to be loaded in " . __METHOD__);
        }
        $result = [];
        foreach ($this->analyze_function_call_plugin_set as $plugin) {
            // TODO: Make this case-insensitive.
            foreach ($plugin->getAnalyzeFunctionCallClosures($code_base) as $fqsen_name => $closure) {
                $other_closure = $result[$fqsen_name] ?? null;
                $closure = self::mergeAnalyzeFunctionCallClosures($closure, $other_closure);
                $result[$fqsen_name] = $closure;
            }
        }
        return $result;
    }

    /**
     * @param CodeBase $code_base
     * @return array<string,\Closure> maps FQSEN string to closure
     */
    public function getReturnTypeOverrides(CodeBase $code_base): array
    {
        if (is_null($this->plugin_set)) {
            throw new AssertionError("Expected plugins to be loaded in " . __METHOD__);
        }
        $result = [];
        foreach ($this->return_type_override_plugin_set as $plugin) {
            $result += $plugin->getReturnTypeOverrides($code_base);
        }
        return $result;
    }

    /** @var list<IssueInstance> $issues */
    private $deferred_emitted_issues = [];

    /**
     * This method is called before Phan emits an issue not suppressed elsewhere.
     *
     * @return bool true if the issue should be suppressed.
     *              Most plugins should use SuppressionCapability instead,
     *              so that more generic issues can be used to suppress specific issues,
     *              and to avoid interfering with baselines.
     */
    public function onEmitIssue(IssueInstance $issue_instance): bool
    {
        if (is_null($this->plugin_set)) {
            $this->deferred_emitted_issues[] = $issue_instance;
            return false;
        }
        if ($this->deferred_emitted_issues) {
            try {
                foreach ($this->deferred_emitted_issues as $issue) {
                    foreach ($this->subscribe_emit_issue_plugin_set as $plugin) {
                        if ($plugin->onEmitIssue($issue)) {
                            break;
                        }
                    }
                }
            } finally {
                $this->deferred_emitted_issues = [];
            }
        }

        foreach ($this->subscribe_emit_issue_plugin_set as $plugin) {
            if ($plugin->onEmitIssue($issue_instance)) {
                return true;
            }
        }
        return false;
    }

    /** @var ?NodeSelectionPlugin - If the language server requests more information about a node, this may be set (e.g. for "Go To Definition") */
    private $node_selection_plugin;

    /**
     * @internal
     * @see addTemporaryAnalysisPlugin
     */
    public function prepareNodeSelectionPluginForNode(Node $node): void
    {
        if (!$this->node_selection_plugin) {
            \fwrite(STDERR, "Error: " . __METHOD__ . " called before node selection plugin was created\n");
            return;
        }

        // TODO: Track if this has been added already(not necessary yet)

        $kind = $node->kind;
        if (!\is_int($kind)) {
            throw new AssertionError("Invalid kind for node");
        }

        /**
         * @param list<Node|mixed> $parent_node_list
         */
        $closure = static function (CodeBase $code_base, Context $context, Node $node, array $parent_node_list = []): void {
            $visitor = new NodeSelectionVisitor($code_base, $context);
            $visitor->visitCommonImplementation($node, $parent_node_list);
        };

        $this->addNodeSelectionClosureForKind($node->kind, $closure);
    }

    /**
     * Adds a plugin that will stay around until the language client's request has been fulfilled
     * (E.g. a plugin that will analyze the node targeted by "go to definition")
     */
    public function addTemporaryAnalysisPlugin(CodeBase $code_base, ?\Phan\Daemon\Request $request): ?RAII
    {
        if (!$request) {
            return null;
        }
        $node_info_request = $request->getMostRecentNodeInfoRequest();
        if (!$node_info_request) {
            return null;
        }
        $node_selection_plugin = new NodeSelectionPlugin();
        if ($node_info_request instanceof GoToDefinitionRequest) {
            $node_selection_plugin->setNodeSelectorClosure(DefinitionResolver::createGoToDefinitionClosure($node_info_request, $code_base));
        } elseif ($node_info_request instanceof CompletionRequest) {
            $node_selection_plugin->setNodeSelectorClosure(CompletionResolver::createCompletionClosure($node_info_request, $code_base));
        } else {
            throw new AssertionError("Unknown subclass of NodeInfoRequest - Should not happen");
        }
        $this->node_selection_plugin = $node_selection_plugin;

        $old_post_analyze_node_plugin_set = $this->post_analyze_node_plugin_set;

        /*
        $new_post_analyze_node_plugins = self::filterPostAnalysisPlugins([$node_selection_plugin]);
        if (!$new_post_analyze_node_plugins) {
            throw new \RuntimeException("Invalid NodeSelectionPlugin");
        }

        // TODO: This can be removed?
        foreach ($new_post_analyze_node_plugins as $kind => $new_plugin) {
            $this->addNodeSelectionClosureForKind($kind, $new_plugin);
        }
         */

        return new RAII(function () use ($old_post_analyze_node_plugin_set): void {
            $this->post_analyze_node_plugin_set = $old_post_analyze_node_plugin_set;
            $this->node_selection_plugin = null;
        });
    }

    /**
     * @param Closure(CodeBase,Context,Node,array=) $new_plugin
     */
    private function addNodeSelectionClosureForKind(int $kind, Closure $new_plugin): void
    {
        $old_plugin_for_kind = $this->post_analyze_node_plugin_set[$kind] ?? null;
        if ($old_plugin_for_kind) {
            /**
             * @param list<Node> $parent_node_list
             * @suppress PhanInfiniteRecursion the old plugin is referring to a different closure
             */
            $this->post_analyze_node_plugin_set[$kind] = static function (CodeBase $code_base, Context $context, Node $node, array $parent_node_list = []) use ($old_plugin_for_kind, $new_plugin): void {
                $old_plugin_for_kind($code_base, $context, $node, $parent_node_list);
                $new_plugin($code_base, $context, $node, $parent_node_list);
            };
        } else {
            $this->post_analyze_node_plugin_set[$kind] = $new_plugin;
        }
    }

    /**
     * Returns true if analyzeProperty() will execute any plugins.
     */
    private function hasAnalyzePropertyPlugins(): bool
    {
        if (is_null($this->plugin_set)) {
            throw new AssertionError("Expected plugins to be loaded in " . __METHOD__);
        }
        // @phan-suppress-next-line PhanPossiblyNullTypeArgumentInternal
        return \count($this->analyze_property_plugin_set) > 0;
    }

    /**
     * Given a plugin's name in the config, return the path Phan expects the plugin to be located in
     * Allow any word/UTF-8 identifier as a php file name.
     * E.g. 'AlwaysReturnPlugin' becomes /path/to/phan/.phan/plugins/AlwaysReturnPlugin.php
     * (Useful when using phan.phar, etc.)
     *
     * @internal
     */
    public static function normalizePluginPath(string $plugin_file_name): string
    {
        if (\preg_match('@^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$@', $plugin_file_name) > 0) {
            $plugin_file_name = self::getBuiltinPluginDirectory() . '/' . $plugin_file_name . '.php';
        }
        if (\preg_match('@^phar://@', $plugin_file_name)) {
            // This is a plugin, probably from phan.phar
            return $plugin_file_name;
        }
        return Config::projectPath($plugin_file_name);
    }

    /**
     * Returns the path to the plugins bundled with Phan.
     */
    public static function getBuiltinPluginDirectory(): string
    {
        return \dirname(__DIR__, 3) . '/.phan/plugins';
    }

    private function ensurePluginsExist(): void
    {
        if (!is_null($this->plugin_set)) {
            return;
        }
        $loaded_plugin_files = [];
        $load_plugin = static function (string $plugin_file_name) use (&$loaded_plugin_files): ?PluginV3 {
            $plugin_file_name = self::normalizePluginPath($plugin_file_name);
            if (isset($loaded_plugin_files[$plugin_file_name])) {
                return null;
            }
            $loaded_plugin_files[$plugin_file_name] = true;

            try {
                $plugin_instance = require($plugin_file_name);
            } catch (Throwable $e) {
                // An unexpected error.
                // E.g. a plugin class threw a SyntaxError because it required PHP 7.1 or newer but 7.0 was used.
                $message = \sprintf(
                    "Failed to initialize plugin %s, exiting: %s: %s at %s:%d\nStack Trace:\n%s",
                    $plugin_file_name,
                    get_class($e),
                    $e->getMessage(),
                    $e->getFile(),
                    $e->getLine(),
                    $e->getTraceAsString()
                );
                \error_log($message);
                exit(EXIT_FAILURE);
            }

            if (!is_object($plugin_instance)) {
                throw new AssertionError("Plugins must return an instance of the plugin. The plugin at $plugin_file_name does not return an object.");
            }

            if (!($plugin_instance instanceof PluginV3)) {
                throw new AssertionError("Plugins must extend \Phan\PluginV3. The plugin at $plugin_file_name does not.");
            }

            return $plugin_instance;
        };
        // Add user-defined plugins.
        $plugin_set = \array_values(\array_filter(\array_map(
            $load_plugin,
            Config::getValue('plugins')
        )));
        // Add internal plugins. Can be disabled by disable_internal_return_type_plugins.
        if (Config::getValue('enable_internal_return_type_plugins')) {
            $internal_return_type_plugins = [
                new ArrayReturnTypeOverridePlugin(),
                new CallableParamPlugin(),
                new CompactPlugin(),
                new ClosureReturnTypeOverridePlugin(),
                new DependentReturnTypeOverridePlugin(),
                new StringFunctionPlugin(),
                new MiscParamPlugin(),
            ];
            if (Config::getValue('enable_extended_internal_return_type_plugins')) {
                \array_unshift($internal_return_type_plugins, new ExtendedDependentReturnTypeOverridePlugin());
            }
            $plugin_set = \array_merge($internal_return_type_plugins, $plugin_set);
        }
        if (Config::getValue('redundant_condition_detection')) {
            $plugin_set[] = new RedundantConditionCallPlugin();
        }
        if (Config::getValue('enable_include_path_checks')) {
            $plugin_set[] = new RequireExistsPlugin();
        }
        if (Config::getValue('warn_about_undocumented_throw_statements')) {
            $plugin_set[] = new ThrowAnalyzerPlugin();
        }
        if (Config::getValue('unused_variable_detection') || Config::getValue('dead_code_detection')) {
            $plugin_set[] = new VariableTrackerPlugin();
        }
        if (self::requiresPluginBasedBuiltinSuppressions()) {
            if (\function_exists('token_get_all')) {
                $plugin_set[] = new BuiltinSuppressionPlugin();
            } else {
                \fwrite(STDERR, "ext-tokenizer is required for file-based and line-based suppressions to work, as well as the error-tolerant parser fallback." . PHP_EOL);
                \fwrite(STDERR, "(This warning can be disabled by setting skip_missing_tokenizer_warning in the project's config)" . PHP_EOL);
            }
        }
        if (Config::getValue('dead_code_detection') && \count(self::filterByClass($plugin_set, \UnreachableCodePlugin::class)) === 0) {
            $plugin_set[] = $load_plugin('UnreachableCodePlugin');
        }

        // The baseline saving plugin will save all issues that weren't suppressed by line suppressions, file suppressions, and global Phan suppressions.
        $save_baseline_path = Config::getValue('__save_baseline_path');
        if ($save_baseline_path) {
            // TODO: Phan isn't currently capable of saving a baseline when there are multiple processes.
            $plugin_set[] = new BaselineSavingPlugin($save_baseline_path);
        }
        // NOTE: The baseline loading plugin is deliberately loaded after the saving plugin,
        // so that BaselineSavingPlugin will read the issues before they get filtered out by the baseline.
        $load_baseline_path = Config::getValue('baseline_path');
        if ($load_baseline_path) {
            if (\is_readable($load_baseline_path)) {
                $plugin_set[] = new BaselineLoadingPlugin($load_baseline_path);
            } else {
                \fwrite(STDERR, CLI::colorizeHelpSectionIfSupported("WARNING: ") . "Could not load baseline from file '$load_baseline_path'" . PHP_EOL);
            }
        }

        // Register the entire set.
        $this->plugin_set = $plugin_set;

        $this->pre_analyze_node_plugin_set      = self::filterPreAnalysisPlugins($plugin_set);
        $this->post_analyze_node_plugin_set     = self::filterPostAnalysisPlugins($plugin_set);
        $this->before_analyze_plugin_set        = self::filterByClass($plugin_set, BeforeAnalyzeCapability::class);
        $this->before_analyze_phase_plugin_set  = self::filterByClass($plugin_set, BeforeAnalyzePhaseCapability::class);
        $this->before_analyze_file_plugin_set   = self::filterByClass($plugin_set, BeforeAnalyzeFileCapability::class);
        $this->after_analyze_file_plugin_set    = self::filterByClass($plugin_set, AfterAnalyzeFileCapability::class);
        $this->analyze_method_plugin_set        = self::filterByClass($plugin_set, AnalyzeMethodCapability::class);
        $this->analyze_function_plugin_set      = self::filterByClass($plugin_set, AnalyzeFunctionCapability::class);
        $this->analyze_property_plugin_set      = self::filterByClass($plugin_set, AnalyzePropertyCapability::class);
        $this->analyze_class_plugin_set         = self::filterByClass($plugin_set, AnalyzeClassCapability::class);
        $this->finalize_process_plugin_set      = self::filterByClass($plugin_set, FinalizeProcessCapability::class);
        $this->return_type_override_plugin_set  = self::filterByClass($plugin_set, ReturnTypeOverrideCapability::class);
        $this->subscribe_emit_issue_plugin_set  = self::filterByClass($plugin_set, SubscribeEmitIssueCapability::class);
        $this->suppression_plugin_set           = self::filterByClass($plugin_set, SuppressionCapability::class, \Phan\PluginV2\SuppressionCapability::class);
        $this->analyze_function_call_plugin_set = self::filterByClass($plugin_set, AnalyzeFunctionCallCapability::class);
        $this->handle_lazy_load_internal_function_plugin_set = self::filterByClass($plugin_set, HandleLazyLoadInternalFunctionCapability::class);
        $this->unused_suppression_plugin        = self::findUnusedSuppressionPlugin($plugin_set);
        self::registerIssueFixerClosures($plugin_set);
    }

    /**
     * @param list<PluginV3> $plugin_set
     */
    private static function registerIssueFixerClosures(array $plugin_set): void
    {
        if (!Config::isIssueFixingPluginEnabled()) {
            // Don't load these if we won't need them.
            return;
        }
        // NOTE: Currently limited to exactly one closure per issue type
        // (the last plugin ends up taking precedence)
        foreach (self::filterByClass($plugin_set, AutomaticFixCapability::class) as $fixer) {
            foreach ($fixer->getAutomaticFixers() as $issue_type => $closure) {
                IssueFixer::registerFixerClosure($issue_type, $closure);
            }
        }
    }

    private static function requiresPluginBasedBuiltinSuppressions(): bool
    {
        if (Config::getValue('disable_suppression')) {
            return false;
        }
        if (Config::getValue('disable_line_based_suppression') && Config::getValue('disable_file_based_suppression')) {
            return false;
        }
        return true;
    }

    /**
     * @param list<PluginV3> $plugin_set
     * @return associative-array<int, Closure(CodeBase,Context,Node,list<Node>=):void>
     *         Returned value maps ast\Node->kind to [function(CodeBase $code_base, Context $context, Node $node, list<Node> $parent_node_list = []): void]
     */
    private static function filterPreAnalysisPlugins(array $plugin_set): array
    {
        $closures_for_kind = new ClosuresForKind();
        foreach ($plugin_set as $plugin) {
            if ($plugin instanceof PreAnalyzeNodeCapability) {
                self::addClosuresForPreAnalyzeNodeCapability($closures_for_kind, $plugin);
            }
        }
        /**
         * @param list<Closure> $closure_list
         */
        return $closures_for_kind->getFlattenedClosures(static function (array $closure_list): Closure {
            return static function (CodeBase $code_base, Context $context, Node $node) use ($closure_list): void {
                foreach ($closure_list as $closure) {
                    $closure($code_base, $context, $node);
                }
            };
        });
    }

    private static function addClosuresForPreAnalyzeNodeCapability(
        ClosuresForKind $closures_for_kind,
        PreAnalyzeNodeCapability $plugin
    ): void {
        $plugin_analysis_class = $plugin->getPreAnalyzeNodeVisitorClassName();
        if (!\is_subclass_of($plugin_analysis_class, PluginAwarePreAnalysisVisitor::class) && !\is_subclass_of($plugin_analysis_class, \Phan\PluginV2\PluginAwarePreAnalysisVisitor::class)) {
            throw new \TypeError(
                \sprintf(
                    "Result of %s::getAnalyzeNodeVisitorClassName must be the name of a subclass of '%s', but '%s' is not",
                    \get_class($plugin),
                    PluginAwarePreAnalysisVisitor::class,
                    $plugin_analysis_class
                )
            );
        }
        // @see PreAnalyzeNodeCapability (magic to create parent_node_list)
        $closure = self::getGenericClosureForPluginAwarePreAnalysisVisitor($plugin_analysis_class);
        $handled_node_kinds = $plugin_analysis_class::getHandledNodeKinds();
        if (\count($handled_node_kinds) === 0) {
            \fprintf(
                STDERR,
                "Plugin %s has a preAnalyzeNode visitor %s (subclass of %s) which doesn't override any known visit<Suffix>() methods, but expected at least one method to be overridden\n",
                \get_class($plugin),
                $plugin_analysis_class,
                PluginAwarePreAnalysisVisitor::class
            );
        }
        $closures_for_kind->recordForKinds($handled_node_kinds, $closure);
    }

    /**
     * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind, in pre-order.
     *
     * @return Closure(CodeBase,Context,Node,array=)
     */
    private static function getGenericClosureForPluginAwarePreAnalysisVisitor(string $plugin_analysis_class): Closure
    {
        if (property_exists($plugin_analysis_class, 'parent_node_list')) {
            /**
             * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind.
             *
             * @param list<Node> $parent_node_list
             * @phan-closure-scope PluginAwarePreAnalysisVisitor
             */
            return (static function (CodeBase $code_base, Context $context, Node $node, array $parent_node_list = []): void {
                $visitor = new static($code_base, $context);
                // @phan-suppress-next-line PhanUndeclaredProperty checked via $has_parent_node_list
                $visitor->parent_node_list = $parent_node_list;
                $fn_name = Element::VISIT_LOOKUP_TABLE[$node->kind];
                $visitor->{$fn_name}($node);
            })->bindTo(null, $plugin_analysis_class);
        } else {
            /**
             * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind.
             *
             * @phan-closure-scope PluginAwarePreAnalysisVisitor
             * @param list<Node> $unused_parent_node_list
             */
            return (static function (CodeBase $code_base, Context $context, Node $node, array $unused_parent_node_list = []): void {
                $visitor = new static($code_base, $context);
                $fn_name = Element::VISIT_LOOKUP_TABLE[$node->kind];
                $visitor->{$fn_name}($node);
            })->bindTo(null, $plugin_analysis_class);
        }
    }

    /**
     * @param list<PluginV3> $plugin_set
     * @return associative-array<int, \Closure> - [Node kind => function(CodeBase $code_base, Context $context, Node $node, list<Node> $parent_node_list = []): void]
     */
    private static function filterPostAnalysisPlugins(array $plugin_set): array
    {
        $closures_for_kind = new ClosuresForKind();
        foreach ($plugin_set as $plugin) {
            if ($plugin instanceof PostAnalyzeNodeCapability) {
                self::addClosuresForPostAnalyzeNodeCapability($closures_for_kind, $plugin);
            }
        }
        /**
         * @param list<Closure> $closure_list
         */
        return $closures_for_kind->getFlattenedClosures(static function (array $closure_list): Closure {
            /**
             * @param list<Node> $parent_node_list
             */
            return static function (CodeBase $code_base, Context $context, Node $node, array $parent_node_list = []) use ($closure_list): void {
                foreach ($closure_list as $closure) {
                    $closure($code_base, $context, $node, $parent_node_list);
                }
            };
        });
    }

    /**
     * @throws \TypeError if the returned getPostAnalyzeNodeVisitorClassName() is invalid
     */
    private static function addClosuresForPostAnalyzeNodeCapability(
        ClosuresForKind $closures_for_kind,
        PostAnalyzeNodeCapability $plugin
    ): void {
        $plugin_analysis_class = $plugin->getPostAnalyzeNodeVisitorClassName();
        if (!\is_subclass_of($plugin_analysis_class, PluginAwarePostAnalysisVisitor::class) && !\is_subclass_of($plugin_analysis_class, \Phan\PluginV2\PluginAwarePostAnalysisVisitor::class)) {
            throw new \TypeError(
                \sprintf(
                    "Result of %s::getAnalyzeNodeVisitorClassName must be the name of a subclass of '%s', but '%s' is not",
                    \get_class($plugin),
                    PluginAwarePostAnalysisVisitor::class,
                    $plugin_analysis_class
                )
            );
        }

        // @see PostAnalyzeNodeCapability (magic to create parent_node_list)
        $closure = self::getGenericClosureForPluginAwarePostAnalysisVisitor($plugin_analysis_class);

        $handled_node_kinds = $plugin_analysis_class::getHandledNodeKinds();
        if (\count($handled_node_kinds) === 0) {
            \fprintf(
                STDERR,
                "Plugin %s has an analyzeNode visitor %s (subclass of %s) which doesn't override any known visit<Suffix>() methods, but expected at least one method to be overridden\n",
                \get_class($plugin),
                $plugin_analysis_class,
                PluginAwarePostAnalysisVisitor::class
            );
        }
        $closures_for_kind->recordForKinds($handled_node_kinds, $closure);
    }

    /**
     * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind, in post-order.
     *
     * @return Closure(CodeBase,Context,Node,array=)
     */
    private static function getGenericClosureForPluginAwarePostAnalysisVisitor(string $plugin_analysis_class): Closure
    {
        if (property_exists($plugin_analysis_class, 'parent_node_list')) {
            /**
             * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind.
             *
             * @phan-closure-scope PluginAwarePostAnalysisVisitor
             * @param list<Node> $parent_node_list
             */
            return (static function (CodeBase $code_base, Context $context, Node $node, array $parent_node_list = []): void {
                $visitor = new static($code_base, $context);
                // @phan-suppress-next-line PhanUndeclaredProperty checked via $has_parent_node_list
                $visitor->parent_node_list = $parent_node_list;
                $fn_name = Element::VISIT_LOOKUP_TABLE[$node->kind];
                $visitor->{$fn_name}($node);
            })->bindTo(null, $plugin_analysis_class);
        } else {
            /**
             * Create an instance of $plugin_analysis_class and run the visit*() method corresponding to $node->kind.
             *
             * @phan-closure-scope PluginAwarePostAnalysisVisitor
             * @param list<Node> $unused_parent_node_list
             */
            return (static function (CodeBase $code_base, Context $context, Node $node, array $unused_parent_node_list = []): void {
                $visitor = new static($code_base, $context);
                $fn_name = Element::VISIT_LOOKUP_TABLE[$node->kind];
                $visitor->{$fn_name}($node);
            })->bindTo(null, $plugin_analysis_class);
        }
    }

    /**
     * @template T
     * @param list<PluginV3> $plugin_set
     * @param class-string<T> $interface_name
     * @param ?class-string $alternate_interface_name a legacy interface from PluginV2 accepting the same arguments
     * @return list<T>
     * @suppress PhanPartialTypeMismatchReturn unable to infer this
     */
    private static function filterByClass(array $plugin_set, string $interface_name, ?string $alternate_interface_name = null): array
    {
        $result = [];
        foreach ($plugin_set as $plugin) {
            if ($plugin instanceof $interface_name) {
                $result[] = $plugin;
            } elseif ($alternate_interface_name && $plugin instanceof $alternate_interface_name) {
                $result[] = $plugin;
            }
        }
        return $result;
    }

    /**
     * @param PluginV3[] $plugin_set
     */
    private static function findUnusedSuppressionPlugin(array $plugin_set): ?UnusedSuppressionPlugin
    {
        foreach ($plugin_set as $plugin) {
            // Don't use instanceof, avoid triggering class autoloader unnecessarily.
            // (load one less file)
            if (\get_class($plugin) === UnusedSuppressionPlugin::class) {
                return $plugin;
            }
        }
        return null;
    }

    /**
     * If an internal function is loaded after the start of the analysis phase,
     * notify plugins in case they need to make modifications to the Func information or the way that Func is handled.
     */
    public function handleLazyLoadInternalFunction(CodeBase $code_base, Func $function): void
    {
        if (!$this->did_analyze_phase_start) {
            return;
        }
        foreach ($this->handle_lazy_load_internal_function_plugin_set as $plugin) {
            $plugin->handleLazyLoadInternalFunction($code_base, $function);
        }
    }
}
