from collections.abc import Generator
import errno
import io
import logging
import os
from pathlib import Path
import select
import subprocess
import sys
import time

import pytest

from esphome.core import CORE
from esphome.log import AnsiFore, AnsiStyle, color, setup_log


class _FakeTty(io.StringIO):
    def isatty(self) -> bool:
        return True


@pytest.fixture
def restore_logging_state() -> Generator[None, None, None]:
    """Undo the global logging changes setup_log() makes."""
    root = logging.getLogger()
    handlers = root.handlers[:]
    formatters = [handler.formatter for handler in handlers]
    level = root.level
    urllib3_level = logging.getLogger("urllib3").level
    yield
    root.handlers[:] = handlers
    for handler, formatter in zip(handlers, formatters, strict=True):
        handler.setFormatter(formatter)
    root.setLevel(level)
    logging.getLogger("urllib3").setLevel(urllib3_level)


def _probe_command(fixture_path: Path, *args: str) -> list[str]:
    """Build the command line for the setup_log probe fixture script."""
    return [sys.executable, str(fixture_path / "log" / "setup_log_probe.py"), *args]


def test_color_keep_returns_unchanged_message() -> None:
    """Test that AnsiFore.KEEP returns the message unchanged."""
    msg = "test message"
    result = color(AnsiFore.KEEP, msg)
    assert result == msg


def test_color_keep_ignores_reset_parameter() -> None:
    """Test that reset parameter is ignored when using AnsiFore.KEEP."""
    msg = "test message"
    result_with_reset = color(AnsiFore.KEEP, msg, reset=True)
    result_without_reset = color(AnsiFore.KEEP, msg, reset=False)
    assert result_with_reset == msg
    assert result_without_reset == msg


def test_color_applies_color_code() -> None:
    """Test that color codes are properly applied to messages."""
    msg = "test message"
    result = color(AnsiFore.RED, msg, reset=False)
    assert result == f"{AnsiFore.RED.value}{msg}"


def test_color_applies_reset_when_requested() -> None:
    """Test that RESET_ALL is added when reset=True."""
    msg = "test message"
    result = color(AnsiFore.GREEN, msg, reset=True)
    expected = f"{AnsiFore.GREEN.value}{msg}{AnsiStyle.RESET_ALL.value}"
    assert result == expected


def test_color_no_reset_when_not_requested() -> None:
    """Test that RESET_ALL is not added when reset=False."""
    msg = "test message"
    result = color(AnsiFore.BLUE, msg, reset=False)
    expected = f"{AnsiFore.BLUE.value}{msg}"
    assert result == expected


def test_color_with_empty_message() -> None:
    """Test color function with empty message."""
    result = color(AnsiFore.YELLOW, "", reset=True)
    expected = f"{AnsiFore.YELLOW.value}{AnsiStyle.RESET_ALL.value}"
    assert result == expected


@pytest.mark.parametrize(
    "col",
    [
        AnsiFore.BLACK,
        AnsiFore.RED,
        AnsiFore.GREEN,
        AnsiFore.YELLOW,
        AnsiFore.BLUE,
        AnsiFore.MAGENTA,
        AnsiFore.CYAN,
        AnsiFore.WHITE,
        AnsiFore.RESET,
    ],
)
def test_all_ansi_colors(col: AnsiFore) -> None:
    """Test that all AnsiFore colors work correctly."""
    msg = "test"
    result = color(col, msg, reset=True)
    expected = f"{col.value}{msg}{AnsiStyle.RESET_ALL.value}"
    assert result == expected


def test_ansi_fore_keep_is_enum_member() -> None:
    """Ensure AnsiFore.KEEP is an Enum member and evaluates to truthy."""
    assert isinstance(AnsiFore.KEEP, AnsiFore)
    # Enum members are truthy, even with empty string values
    assert bool(AnsiFore.KEEP) is True
    # But the value itself is still an empty string
    assert AnsiFore.KEEP.value == ""


@pytest.mark.skipif(
    sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_redirected_output_strips_ansi(
    fixture_path: Path, probe_env: dict[str, str]
) -> None:
    """A redirected run must keep colorama so ANSI codes are stripped."""
    result = subprocess.run(
        _probe_command(fixture_path),
        capture_output=True,
        text=True,
        timeout=60,
        check=False,
        env=probe_env,
    )
    assert result.returncode == 0, result.stderr
    assert "colorama_loaded=True" in result.stdout
    assert "red end" in result.stdout
    assert "\033" not in result.stdout


@pytest.mark.skipif(
    sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_dashboard_skips_colorama(
    fixture_path: Path, probe_env: dict[str, str]
) -> None:
    """Dashboard runs escape their color codes, so colorama must not load."""
    result = subprocess.run(
        _probe_command(fixture_path, "--dashboard"),
        capture_output=True,
        text=True,
        timeout=60,
        check=False,
        env=probe_env,
    )
    assert result.returncode == 0, result.stderr
    assert "colorama_loaded=False" in result.stdout
    # Codes pass through untouched for the dashboard to handle.
    assert "\033[31mred\033[0m end" in result.stdout


def _run_probe_on_pty(
    fixture_path: Path, probe_env: dict[str, str], *, stderr_to_pty: bool
) -> str:
    """Run the probe with stdout on a pty and return the decoded pty output.

    With ``stderr_to_pty=False`` stderr goes to a pipe instead, giving the
    mixed tty/redirect stream combination while keeping any traceback
    available for the exit assertion.
    """
    # Unix-only; a module-level import would break test collection on
    # Windows, where all the callers are skipped anyway.
    import pty

    controller, follower = pty.openpty()
    proc = None
    output = b""
    deadline = time.monotonic() + 60
    try:
        try:
            proc = subprocess.Popen(
                _probe_command(fixture_path),
                stdout=follower,
                stderr=follower if stderr_to_pty else subprocess.PIPE,
                stdin=follower,
                env=probe_env,
            )
        finally:
            os.close(follower)
        while True:
            timeout = deadline - time.monotonic()
            if timeout <= 0 or not select.select([controller], [], [], timeout)[0]:
                pytest.fail(f"pty probe produced no EOF in time; got {output!r}")
            try:
                chunk = os.read(controller, 1024)
            except OSError as err:
                # macOS raises EIO once the child closes its end of the pty;
                # anything else is a real failure, not end-of-stream.
                if err.errno != errno.EIO:
                    raise
                break
            if not chunk:
                break
            output += chunk
        stderr_text = ""
        if proc.stderr is not None:
            stderr_text = proc.stderr.read().decode(errors="replace")
            proc.stderr.close()
        assert proc.wait(60) == 0, stderr_text
    finally:
        os.close(controller)
        if proc is not None and proc.poll() is None:
            proc.kill()
            proc.wait()
    return output.decode()


@pytest.mark.skipif(
    sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
)
def test_setup_log_tty_skips_colorama(
    fixture_path: Path, probe_env: dict[str, str]
) -> None:
    """A terminal run must skip colorama and keep ANSI codes intact."""
    text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=True)
    assert "colorama_loaded=False" in text
    assert "\033[31mred\033[0m end" in text


@pytest.mark.skipif(
    sys.platform == "win32", reason="pty is POSIX-only; colorama loads on Windows"
)
def test_setup_log_mixed_streams_init_colorama(
    fixture_path: Path, probe_env: dict[str, str]
) -> None:
    """A tty stdout with a redirected stderr must still initialize colorama.

    The guard requires both streams to be a tty; collapsing it to a
    single-stream check would stop stripping ANSI from a redirected
    stderr while stdout is a terminal.
    """
    text = _run_probe_on_pty(fixture_path, probe_env, stderr_to_pty=False)
    assert "colorama_loaded=True" in text
    # stdout is a tty, so colorama leaves its codes alone.
    assert "\033[31mred\033[0m end" in text


@pytest.fixture
def colorama_probe(
    monkeypatch: pytest.MonkeyPatch, restore_logging_state: None
) -> Generator[None, None, None]:
    """Shared preamble for the in-process guard-branch tests.

    Clears colorama from sys.modules so the assertions prove what
    setup_log() itself did, and snapshots CORE.verbose/quiet, which is
    not a no-op: CORE.reset() does not restore them, so without the
    snapshot setup_log()'s log-level side effects would leak into later
    tests.
    """
    monkeypatch.delitem(sys.modules, "colorama", raising=False)
    monkeypatch.setattr(CORE, "verbose", CORE.verbose)
    monkeypatch.setattr(CORE, "quiet", CORE.quiet)
    yield
    # init() rebinds sys.stdout/stderr; restore them before monkeypatch
    # puts the originals back.
    if (colorama := sys.modules.get("colorama")) is not None:
        colorama.deinit()


@pytest.mark.skipif(
    sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_dashboard_branch_skips_colorama_import(
    monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
    """The dashboard side of the guard must not import colorama."""
    monkeypatch.setattr(CORE, "dashboard", True)
    setup_log()
    assert "colorama" not in sys.modules


@pytest.mark.skipif(
    sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_tty_branch_skips_colorama_import(
    monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
    """The tty side of the guard must not import colorama."""
    monkeypatch.setattr(sys, "stdout", _FakeTty())
    monkeypatch.setattr(sys, "stderr", _FakeTty())
    setup_log()
    assert "colorama" not in sys.modules


@pytest.mark.skipif(
    sys.platform == "win32", reason="colorama always initializes on Windows"
)
def test_setup_log_redirected_branch_imports_colorama(
    monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
    """Redirected streams must keep importing and initializing colorama."""
    monkeypatch.setattr(sys, "stdout", io.StringIO())
    monkeypatch.setattr(sys, "stderr", io.StringIO())
    setup_log()
    assert "colorama" in sys.modules


@pytest.mark.parametrize("broken", ["missing", "closed"])
def test_setup_log_broken_streams_import_colorama(
    broken: str, monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
    """A missing or closed stream counts as a redirect and must not crash.

    colorama tolerates both, so setup_log() has to reach its init rather
    than raise inside the tty probe.
    """
    if broken == "missing":
        stream = None
    else:
        stream = io.StringIO()
        stream.close()
    monkeypatch.setattr(sys, "stdout", stream)
    monkeypatch.setattr(sys, "stderr", stream)
    setup_log()
    assert "colorama" in sys.modules


def test_setup_log_win32_always_imports_colorama(
    monkeypatch: pytest.MonkeyPatch, colorama_probe: None
) -> None:
    """The Windows clause must init colorama even when both streams are ttys.

    Old Windows consoles need colorama to translate ANSI escapes, so the
    platform check has to win over the tty check. colorama itself keys
    off os.name, so on a POSIX host its init/deinit pair is a
    passthrough.
    """
    monkeypatch.setattr(sys, "platform", "win32")
    # Both streams are ttys: without the platform clause this combination
    # would skip colorama.
    monkeypatch.setattr(sys, "stdout", _FakeTty())
    monkeypatch.setattr(sys, "stderr", _FakeTty())
    setup_log()
    assert "colorama" in sys.modules
