from __future__ import annotations

import logging
import sys
from typing import TYPE_CHECKING

import pytest

from scrapy import signals
from scrapy.core import engine as engine_mod
from scrapy.exceptions import NotConfigured
from scrapy.extensions import memusage as memusage_mod
from scrapy.extensions.memusage import MemoryUsage
from scrapy.spiders import Spider
from scrapy.utils.misc import build_from_crawler
from scrapy.utils.test import get_crawler
from tests.utils import OneShotLoop
from tests.utils.cmdline import proc
from tests.utils.decorators import coroutine_test

if TYPE_CHECKING:
    from tests.mockserver.http import MockServer

# MemoryUsage relies on the stdlib 'resource' module (not available on Windows)
pytestmark = pytest.mark.skipif(
    sys.platform.startswith("win"),
    reason="MemoryUsage extension not available on Windows",
)


MB = 1024 * 1024


class TwoShotLoop(OneShotLoop):
    """Like :class:`OneShotLoop`, but runs the check twice."""

    def start(self, interval: float, now: bool = True) -> None:
        super().start(interval, now=now)
        self.func()


class _LoopSpider(Spider):
    name = "loop-data-spider"

    def __init__(self, url: str, loops: int = 60, **kw):
        super().__init__(**kw)
        self.url = url
        self.loops = loops
        self.start_urls = [url]

    def parse(self, response):
        count = response.meta.get("count", 0)
        if count + 1 < self.loops:
            yield response.follow(
                self.url, callback=self.parse, meta={"count": count + 1}
            )


def test_memusage_disabled() -> None:
    settings = {
        "MEMUSAGE_ENABLED": False,
    }
    with pytest.raises(NotConfigured):
        build_from_crawler(MemoryUsage, get_crawler(settings_dict=settings))


def test_memusage_limit_stops_crawler_without_spider(mockserver: MockServer) -> None:
    # The Scrapy shell starts the engine without opening a spider, so the
    # whole crawler is stopped instead of a spider being closed.
    _, out, err = proc(
        "shell",
        mockserver.url("/text"),
        "-c",
        "response.status",
        "--set",
        "MEMUSAGE_LIMIT_MB=1",
    )
    assert "Memory usage exceeded 1MiB" in err
    assert "200" in out


@coroutine_test
async def test_memusage_below_thresholds_logs_peak(
    caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
    settings = {
        "MEMUSAGE_LIMIT_MB": 100,
        "MEMUSAGE_WARNING_MB": 50,
        "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01,
        "TELNETCONSOLE_ENABLED": False,
        "LOG_LEVEL": "INFO",
    }

    monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop)
    monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda _: 25 * MB)

    crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings)

    with caplog.at_level(logging.INFO, logger="scrapy.extensions.memusage"):
        await crawler.crawl_async(url="data:,", loops=1)

    assert crawler.stats.get_value("memusage/limit_reached") is None
    assert crawler.stats.get_value("memusage/warning_reached") is None
    assert crawler.stats.get_value("memusage/max") == 25 * MB
    assert crawler.stats.get_value("finish_reason") == "finished"
    assert any("Peak memory usage is 25MiB" in r.getMessage() for r in caplog.records)


@coroutine_test
async def test_memusage_limit_closes_spider_with_reason_and_error_log(
    caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
    settings = {
        "MEMUSAGE_LIMIT_MB": 10,
        "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01,
        "TELNETCONSOLE_ENABLED": False,
        "LOG_LEVEL": "INFO",
    }

    # Avoid background LoopingCall that can log after the test finishes.
    monkeypatch.setattr(memusage_mod, "create_looping_call", OneShotLoop)
    # Avoid engine start/stop races (the extension stops the engine in engine_started).
    monkeypatch.setattr(engine_mod, "create_looping_call", OneShotLoop)
    monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda _: 250 * MB)

    crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings)

    with caplog.at_level(logging.ERROR, logger="scrapy.extensions.memusage"):
        await crawler.crawl_async(url="data:,", loops=100)

    assert crawler.stats.get_value("memusage/limit_reached") == 1
    assert crawler.stats.get_value("finish_reason") == "memusage_exceeded"
    assert any(
        "memory usage exceeded" in r.getMessage().lower() for r in caplog.records
    )


@coroutine_test
async def test_memusage_warning_logs_but_allows_normal_finish(
    caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
    settings = {
        "MEMUSAGE_WARNING_MB": 50,
        "MEMUSAGE_LIMIT_MB": 0,  # no hard limit
        "MEMUSAGE_CHECK_INTERVAL_SECONDS": 0.01,
        "TELNETCONSOLE_ENABLED": False,
        "LOG_LEVEL": "INFO",
    }

    # Avoid background LoopingCall that can log after the test finishes; check
    # twice, since the warning is only meant to be reported once.
    monkeypatch.setattr(memusage_mod, "create_looping_call", TwoShotLoop)
    monkeypatch.setattr(MemoryUsage, "get_virtual_size", lambda self: 75 * MB)

    crawler = get_crawler(spidercls=_LoopSpider, settings_dict=settings)

    warning_signals: list[int] = []

    def on_warning_reached() -> None:
        warning_signals.append(1)

    crawler.signals.connect(on_warning_reached, signal=signals.memusage_warning_reached)

    with caplog.at_level(logging.WARNING, logger="scrapy.extensions.memusage"):
        await crawler.crawl_async(url="data:,", loops=60)

    assert warning_signals == [1]
    assert crawler.stats.get_value("memusage/warning_reached") == 1
    assert crawler.stats.get_value("finish_reason") == "finished"
    warnings_logged = [
        r for r in caplog.records if "memory usage reached" in r.getMessage().lower()
    ]
    assert len(warnings_logged) == 1
