# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.

"""
Tests for L{twisted.application.twist._options}.
"""

from __future__ import annotations

from sys import stderr, stdout
from typing import TYPE_CHECKING, Callable, TextIO

if TYPE_CHECKING:
    from types import NotImplementedType

import twisted.trial.unittest
from twisted.copyright import version
from twisted.internet import reactor
from twisted.internet.interfaces import IReactorCore
from twisted.internet.testing import MemoryReactor
from twisted.logger import (
    FileLogObserver,
    LogLevel,
    jsonFileLogObserver,
    textFileLogObserver,
)
from twisted.python.usage import UsageError
from ...reactors import NoSuchReactor
from ...runner._exit import ExitStatus
from ...runner.test.test_runner import DummyExit
from ...service import ServiceMaker
from ...twist import _options
from .._options import TwistOptions


class OptionsTests(twisted.trial.unittest.TestCase):
    """
    Tests for L{TwistOptions}.
    """

    def patchExit(self) -> None:
        """
        Patch L{_twist.exit} so we can capture usage and prevent actual exits.
        """
        self.exit = DummyExit()
        self.patch(_options, "exit", self.exit)

    def patchOpen(self) -> None:
        """
        Patch L{_options.open} so we can capture usage and prevent actual opens.
        """
        self.opened: list[tuple[str, str | None]] = []

        def fakeOpen(name: str, mode: str | None = None) -> TextIO | NotImplementedType:
            if name == "nocanopen":
                raise OSError(None, None, name)

            self.opened.append((name, mode))
            # https://github.com/python/mypy/issues/18914
            return NotImplemented  # type:ignore[no-any-return]

        self.patch(_options, "openFile", fakeOpen)

    def patchInstallReactor(self) -> None:
        """
        Patch C{_options.installReactor} so we can capture usage and prevent
        actual installs.
        """
        self.installedReactors: dict[str, IReactorCore] = {}

        def installReactor(name: str) -> IReactorCore:
            if name != "fusion":
                raise NoSuchReactor()

            reactor = MemoryReactor()
            self.installedReactors[name] = reactor
            return reactor

        self.patch(_options, "installReactor", installReactor)

    def test_synopsis(self) -> None:
        """
        L{TwistOptions.getSynopsis} appends arguments.
        """
        options = TwistOptions()

        self.assertTrue(options.getSynopsis().endswith(" plugin [plugin_options]"))

    def test_version(self) -> None:
        """
        L{TwistOptions.opt_version} exits with L{ExitStatus.EX_OK} and prints
        the version.
        """
        self.patchExit()

        options = TwistOptions()
        options.opt_version()

        self.assertEqual(self.exit.status, ExitStatus.EX_OK)  # type: ignore[unreachable]
        self.assertEqual(self.exit.message, version)

    def test_reactor(self) -> None:
        """
        L{TwistOptions.installReactor} installs the chosen reactor and sets
        the reactor name.
        """
        self.patchInstallReactor()

        options = TwistOptions()
        options.opt_reactor("fusion")

        self.assertEqual(set(self.installedReactors), {"fusion"})
        self.assertEqual(options["reactorName"], "fusion")

    def test_installCorrectReactor(self) -> None:
        """
        L{TwistOptions.installReactor} installs the chosen reactor after the
        command line options have been parsed.
        """
        self.patchInstallReactor()

        options = TwistOptions()
        options.subCommand = "test-subcommand"
        options.parseOptions(["--reactor=fusion"])

        self.assertEqual(set(self.installedReactors), {"fusion"})

    def test_installReactorBogus(self) -> None:
        """
        L{TwistOptions.installReactor} raises UsageError if an unknown reactor
        is specified.
        """
        self.patchInstallReactor()

        options = TwistOptions()
        self.assertRaises(UsageError, options.opt_reactor, "coal")

    def test_installReactorDefault(self) -> None:
        """
        L{TwistOptions.installReactor} returns the currently installed reactor
        when the default reactor name is specified.
        """
        options = TwistOptions()
        self.assertIdentical(reactor, options.installReactor("default"))

    def test_logLevelValid(self) -> None:
        """
        L{TwistOptions.opt_log_level} sets the corresponding log level.
        """
        options = TwistOptions()
        options.opt_log_level("warn")

        self.assertIdentical(options["logLevel"], LogLevel.warn)

    def test_logLevelInvalid(self) -> None:
        """
        L{TwistOptions.opt_log_level} with an invalid log level name raises
        UsageError.
        """
        options = TwistOptions()

        self.assertRaises(UsageError, options.opt_log_level, "cheese")

    def _testLogFile(self, name: str, expectedStream: TextIO) -> None:
        """
        Set log file name and check the selected output stream.

        @param name: The name of the file.
        @param expectedStream: The expected stream.
        """
        options = TwistOptions()
        options.opt_log_file(name)

        self.assertIdentical(options["logFile"], expectedStream)

    def test_logFileStdout(self) -> None:
        """
        L{TwistOptions.opt_log_file} given C{"-"} as a file name uses stdout.
        """
        self._testLogFile("-", stdout)

    def test_logFileStderr(self) -> None:
        """
        L{TwistOptions.opt_log_file} given C{"+"} as a file name uses stderr.
        """
        self._testLogFile("+", stderr)

    def test_logFileNamed(self) -> None:
        """
        L{TwistOptions.opt_log_file} opens the given file name in append mode.
        """
        self.patchOpen()

        options = TwistOptions()
        options.opt_log_file("mylog")

        self.assertEqual([("mylog", "a")], self.opened)

    def test_logFileCantOpen(self) -> None:
        """
        L{TwistOptions.opt_log_file} exits with L{ExitStatus.EX_IOERR} if
        unable to open the log file due to an L{EnvironmentError}.
        """
        self.patchExit()
        self.patchOpen()

        options = TwistOptions()
        options.opt_log_file("nocanopen")

        self.assertEqual(self.exit.status, ExitStatus.EX_IOERR)
        self.assertIsNotNone(self.exit.message)
        self.assertTrue(
            self.exit.message.startswith(  # type: ignore[union-attr]
                "Unable to open log file 'nocanopen': "
            )
        )

    def _testLogFormat(
        self, format: str, expectedObserverFactory: Callable[[TextIO], FileLogObserver]
    ) -> None:
        """
        Set log file format and check the selected observer factory.

        @param format: The format of the file.
        @param expectedObserverFactory: The expected observer factory.
        """
        options = TwistOptions()
        options.opt_log_format(format)

        self.assertIdentical(options["fileLogObserverFactory"], expectedObserverFactory)
        self.assertEqual(options["logFormat"], format)

    def test_logFormatText(self) -> None:
        """
        L{TwistOptions.opt_log_format} given C{"text"} uses a
        L{textFileLogObserver}.
        """
        self._testLogFormat("text", textFileLogObserver)

    def test_logFormatJSON(self) -> None:
        """
        L{TwistOptions.opt_log_format} given C{"text"} uses a
        L{textFileLogObserver}.
        """
        self._testLogFormat("json", jsonFileLogObserver)

    def test_logFormatInvalid(self) -> None:
        """
        L{TwistOptions.opt_log_format} given an invalid format name raises
        L{UsageError}.
        """
        options = TwistOptions()

        self.assertRaises(UsageError, options.opt_log_format, "frommage")

    def test_selectDefaultLogObserverNoOverride(self) -> None:
        """
        L{TwistOptions.selectDefaultLogObserver} will not override an already
        selected observer.
        """
        self.patchOpen()

        options = TwistOptions()
        options.opt_log_format("text")  # Ask for text
        options.opt_log_file("queso")  # File, not a tty
        options.selectDefaultLogObserver()

        # Because we didn't select a file that is a tty, the default is JSON,
        # but since we asked for text, we should get text.
        self.assertIdentical(options["fileLogObserverFactory"], textFileLogObserver)
        self.assertEqual(options["logFormat"], "text")

    def test_selectDefaultLogObserverDefaultWithTTY(self) -> None:
        """
        L{TwistOptions.selectDefaultLogObserver} will not override an already
        selected observer.
        """

        class TTYFile:
            def isatty(self) -> bool:
                return True

        # stdout may not be a tty, so let's make sure it thinks it is
        self.patch(_options, "stdout", TTYFile())

        options = TwistOptions()
        options.opt_log_file("-")  # stdout, a tty
        options.selectDefaultLogObserver()

        self.assertIdentical(options["fileLogObserverFactory"], textFileLogObserver)
        self.assertEqual(options["logFormat"], "text")

    def test_selectDefaultLogObserverDefaultWithoutTTY(self) -> None:
        """
        L{TwistOptions.selectDefaultLogObserver} will not override an already
        selected observer.
        """
        self.patchOpen()

        options = TwistOptions()
        options.opt_log_file("queso")  # File, not a tty
        options.selectDefaultLogObserver()

        self.assertIdentical(options["fileLogObserverFactory"], jsonFileLogObserver)
        self.assertEqual(options["logFormat"], "json")

    def test_pluginsType(self) -> None:
        """
        L{TwistOptions.plugins} is a mapping of available plug-ins.
        """
        options = TwistOptions()
        plugins = options.plugins

        for name in plugins:
            self.assertIsInstance(name, str)
            self.assertIsInstance(plugins[name], ServiceMaker)

    def test_pluginsIncludeWeb(self) -> None:
        """
        L{TwistOptions.plugins} includes a C{"web"} plug-in.
        This is an attempt to verify that something we expect to be in the list
        is in there without enumerating all of the built-in plug-ins.
        """
        options = TwistOptions()

        self.assertIn("web", options.plugins)

    def test_subCommandsType(self) -> None:
        """
        L{TwistOptions.subCommands} is an iterable of tuples as expected by
        L{twisted.python.usage.Options}.
        """
        options = TwistOptions()

        for name, shortcut, parser, doc in options.subCommands:
            self.assertIsInstance(name, str)
            self.assertIdentical(shortcut, None)
            self.assertTrue(callable(parser))
            self.assertIsInstance(doc, str)

    def test_subCommandsIncludeWeb(self) -> None:
        """
        L{TwistOptions.subCommands} includes a sub-command for every plug-in.
        """
        options = TwistOptions()

        plugins = set(options.plugins)
        subCommands = {name for name, shortcut, parser, doc in options.subCommands}

        self.assertEqual(subCommands, plugins)

    def test_postOptionsNoSubCommand(self) -> None:
        """
        L{TwistOptions.postOptions} raises L{UsageError} is it has no
        sub-command.
        """
        self.patchInstallReactor()

        options = TwistOptions()

        self.assertRaises(UsageError, options.postOptions)
