import asyncio
import json
import re
from ipaddress import ip_address
from pathlib import Path
from types import SimpleNamespace

import aiohttp
import pytest

from theHarvester.discovery import takeover
from theHarvester.lib.core import FetcherResponse, ResponseStreamError
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome, TakeoverDNSOutcome
from theHarvester.lib.takeover_rules import TakeoverRule


def test_takeover_rules_cover_the_pinned_validated_corpus() -> None:
    fixture_path = Path(__file__).parents[1] / 'fixtures' / 'takeover_can_i_take_over_xyz_5bd4e128.fixture'
    fixture = json.loads(fixture_path.read_text())
    assert fixture['provenance'] == {
        'repository': 'EdOverflow/can-i-take-over-xyz',
        'commit': '5bd4e128',
        'source_sha256': 'a108bf6e6d10d4e4861c4293eef8c224a0fd243ec4f3a39de321de69f284c64f',
        'selection': 'vulnerable == true and cicd_pass == true',
    }
    records = fixture['records']
    assert len(records) == 18
    rules_by_service = {rule.service: rule for rule in takeover.TAKEOVER_RULES}

    for record in records:
        assert record['vulnerable'] is True
        assert record['cicd_pass'] is True
        if record['service'] == 'SmartJobBoard':
            assert all(ip_address(value) for value in record['cname'])
            assert record['service'] not in rules_by_service
            continue

        rule = rules_by_service[record['service']]
        provider_names = []
        for value in record['cname']:
            try:
                ip_address(value)
            except ValueError:
                provider_names.append(value)
        assert provider_names
        assert all(
            any(re.search(pattern, name, flags=re.IGNORECASE) for pattern in rule.cname_patterns) for name in provider_names
        )
        if record['nxdomain']:
            assert 'NXDOMAIN' in rule.terminal_rcodes
        elif record['http_status'] is not None:
            assert record['http_status'] in rule.status_codes
        else:
            fingerprint = record['fingerprint'].casefold()
            literal_markers = (*rule.body_all, *rule.body_any)
            assert any(marker.casefold() in fingerprint or fingerprint in marker.casefold() for marker in literal_markers)


@pytest.mark.asyncio
async def test_takeover_reuses_one_cookie_free_unlimited_http_session(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=() if hostname.startswith('takeover-control-') else ('bucket.s3.amazonaws.com',),
                terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NOERROR',
            )

        async def close(self) -> None:
            return None

    class SharedSession:
        def __init__(self) -> None:
            self.close_count = 0

        async def close(self) -> None:
            self.close_count += 1

    shared_session = SharedSession()
    build_calls: list[dict[str, object]] = []
    fetch_sessions: list[object] = []

    async def fake_build_session(
        headers: dict[str, str],
        client_timeout: aiohttp.ClientTimeout,
        proxy_url: str | None = None,
        proxy_type: str | None = None,
        ssl_context: object = None,
        cookie_jar: aiohttp.abc.AbstractCookieJar | None = None,
    ) -> SharedSession:
        build_calls.append(
            {
                'headers': headers,
                'client_timeout': client_timeout,
                'proxy_url': proxy_url,
                'proxy_type': proxy_type,
                'ssl_context': ssl_context,
                'cookie_jar': cookie_jar,
            }
        )
        return shared_session

    async def fake_fetch_text(_url: str, **kwargs: object) -> FetcherResponse:
        fetch_sessions.append(kwargs['session'])
        return FetcherResponse(
            body='The specified bucket does not exist <BucketName>bucket</BucketName>',
            status=404,
            headers={},
        )

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
    scanner = takeover.TakeoverScanner(
        ['bucket.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    assert len(build_calls) == 1
    assert isinstance(build_calls[0]['cookie_jar'], aiohttp.DummyCookieJar)
    assert build_calls[0]['client_timeout'].total is None
    assert fetch_sessions == [shared_session, shared_session]
    assert shared_session.close_count == 1


@pytest.mark.asyncio
async def test_takeover_requires_provider_dns_evidence_and_compound_http_predicates(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    dns_outcomes = {
        'bucket.example.test': TakeoverDNSOutcome(
            resolver='1.1.1.1',
            cname_chain=('missing-bucket.s3.amazonaws.com',),
            terminal_rcode='NOERROR',
        ),
        'generic.example.test': TakeoverDNSOutcome(
            resolver='1.1.1.1',
            cname_chain=('generic.invalid',),
            terminal_rcode='NOERROR',
        ),
    }

    class FakeResolver:
        def __init__(self, nameserver: str) -> None:
            assert nameserver == '1.1.1.1'
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            return dns_outcomes.get(
                hostname,
                TakeoverDNSOutcome(
                    resolver=self.nameserver,
                    cname_chain=(),
                    terminal_rcode='NXDOMAIN',
                ),
            )

        async def close(self) -> None:
            return None

    requested: list[str] = []

    async def fake_fetch_text(url: str, **kwargs: object) -> FetcherResponse:
        requested.append(url)
        assert kwargs['follow_redirects'] is False
        assert kwargs['response_byte_limit'] == takeover.MAX_TAKEOVER_RESPONSE_BYTES
        return FetcherResponse(
            body='<Code>NoSuchBucket</Code><BucketName>missing-bucket</BucketName>The specified bucket does not exist',
            status=404,
            headers={'content-type': 'application/xml'},
        )

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)

    scanner = takeover.TakeoverScanner(
        ['bucket.example.test', 'generic.example.test:192.0.2.10'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )
    await scanner.process()

    outcomes = await scanner.get_takeover_outcomes()
    assert [outcome.hostname for outcome in outcomes] == ['bucket.example.test', 'generic.example.test']
    bucket, generic = outcomes
    assert bucket.status == 'indicator'
    assert bucket.indicators[0].service == 'AWS/S3'
    assert bucket.indicators[0].classification == 'vulnerable-indicator'
    assert bucket.indicators[0].scheme == 'https'
    assert bucket.http[0].status == 404
    assert set(bucket.indicators[0].matched) == {
        'body:The specified bucket does not exist',
        'body:BucketName',
    }
    assert generic.status == 'no-indicator'
    assert generic.indicators == ()
    assert set(requested) == {'https://bucket.example.test', 'http://bucket.example.test'}
    assert scanner.candidate_count == 2
    assert scanner.completed_count == 2
    assert scanner.request_count == 2
    assert scanner.request_error_count == 0


@pytest.mark.asyncio
async def test_takeover_keeps_dns_only_nxdomain_evidence_without_http(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, _hostname: str) -> TakeoverDNSOutcome:
            if _hostname.startswith('takeover-control-'):
                return TakeoverDNSOutcome(
                    resolver=self.nameserver,
                    cname_chain=(),
                    terminal_rcode='NXDOMAIN',
                )
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=('missing.eu-west-1.elasticbeanstalk.com',),
                terminal_rcode='NXDOMAIN',
            )

        async def close(self) -> None:
            return None

    async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
        raise AssertionError('DNS-only rule must not send an HTTP request')

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)

    scanner = takeover.TakeoverScanner(
        ['app.example.test'],
        target='example.test',
        nameservers=['1.1.1.1', '8.8.8.8'],
    )
    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'indicator'
    assert outcome.indicators[0].service == 'AWS/Elastic Beanstalk'
    assert outcome.indicators[0].matched == ('dns:terminal-rcode=NXDOMAIN',)
    assert {item.resolver for item in outcome.dns} == {'1.1.1.1', '8.8.8.8'}
    assert scanner.request_count == 0


@pytest.mark.asyncio
async def test_takeover_fails_closed_when_proxy_mode_has_no_proxy(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(takeover.AsyncFetcher, '_resolve_proxy', lambda _proxy: (None, None))
    scanner = takeover.TakeoverScanner(
        ['app.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process(proxy=True)

    assert await scanner.get_takeover_outcomes() == ()
    assert scanner.scan_error_type == 'ProxyUnavailableError'
    assert scanner.stop_reason == 'proxy-unavailable'
    assert scanner.completed_count == 0


@pytest.mark.asyncio
async def test_takeover_response_limit_is_partial_and_does_not_stop_siblings(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class FakeResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            if hostname.startswith('takeover-control-'):
                return TakeoverDNSOutcome(
                    resolver=self.nameserver,
                    cname_chain=(),
                    terminal_rcode='NXDOMAIN',
                )
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=(f'{hostname.split(".")[0]}.s3.amazonaws.com',),
                terminal_rcode='NOERROR',
            )

        async def close(self) -> None:
            return None

    async def fake_fetch_text(url: str, **_kwargs: object) -> FetcherResponse:
        if 'large.' in url:
            raise ResponseStreamError('response-limit', status=200, headers={'location': '/retained'})
        return FetcherResponse(
            body='The specified bucket does not exist <BucketName>valid</BucketName>',
            status=404,
            headers={},
        )

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
    scanner = takeover.TakeoverScanner(
        ['large.example.test', 'valid.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    outcomes = await scanner.get_takeover_outcomes()
    assert [item.hostname for item in outcomes] == ['large.example.test', 'valid.example.test']
    assert [item.status for item in outcomes] == ['inconclusive', 'indicator']
    assert outcomes[0].error_types == ('ResponseLimitError',)
    assert {item.location for item in outcomes[0].http} == {'/retained'}
    assert scanner.completed_count == 2
    assert scanner.request_error_count == 2
    assert scanner.request_error_types == {'ResponseLimitError'}


@pytest.mark.asyncio
async def test_takeover_suppresses_provider_like_wildcard_dns(monkeypatch: pytest.MonkeyPatch) -> None:
    class WildcardResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, _hostname: str) -> TakeoverDNSOutcome:
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=('wildcard.s3.amazonaws.com',),
                terminal_rcode='NOERROR',
            )

        async def close(self) -> None:
            return None

    async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
        raise AssertionError('wildcard-indistinguishable DNS must not trigger HTTP')

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', WildcardResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
    scanner = takeover.TakeoverScanner(
        ['bucket.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    outcomes = await scanner.get_takeover_outcomes()
    assert len(outcomes) == 1
    assert outcomes[0].status == 'inconclusive'
    assert outcomes[0].error_types == ('WildcardIndistinguishableError',)
    assert scanner.wildcard_indistinguishable_count == 1
    assert scanner.stop_reason == 'wildcard-indistinguishable'
    assert scanner.request_count == 0


@pytest.mark.asyncio
async def test_takeover_keeps_distinct_provider_cnames_separate_from_wildcard_dns(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class DistinctWildcardResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            cname = (
                'wildcard-bucket.s3.amazonaws.com' if hostname.startswith('takeover-control-') else 'real-bucket.s3.amazonaws.com'
            )
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=(cname,),
                terminal_rcode='NOERROR',
            )

        async def close(self) -> None:
            return None

    async def fake_fetch_text(_url: str, **_kwargs: object) -> FetcherResponse:
        return FetcherResponse(
            body='The specified bucket does not exist <BucketName>real-bucket</BucketName>',
            status=404,
            headers={},
        )

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', DistinctWildcardResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
    scanner = takeover.TakeoverScanner(
        ['bucket.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'indicator'
    assert outcome.dns[0].cname_chain == ('real-bucket.s3.amazonaws.com',)
    assert outcome.wildcard_dns[0].cname_chain == ('wildcard-bucket.s3.amazonaws.com',)


@pytest.mark.asyncio
async def test_takeover_wildcard_control_failure_is_inconclusive(monkeypatch: pytest.MonkeyPatch) -> None:
    class FailedControlResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            if hostname.startswith('takeover-control-'):
                return TakeoverDNSOutcome(
                    resolver=self.nameserver,
                    cname_chain=(),
                    terminal_rcode='ERROR',
                    error_type='TimeoutError',
                )
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=('bucket.s3.amazonaws.com',),
                terminal_rcode='NOERROR',
            )

        async def close(self) -> None:
            return None

    async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
        raise AssertionError('failed wildcard controls must not trigger HTTP')

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FailedControlResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
    scanner = takeover.TakeoverScanner(
        ['bucket.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'inconclusive'
    assert outcome.error_types == ('TimeoutError', 'WildcardControlError')
    assert outcome.wildcard_dns[0].error_type == 'TimeoutError'


@pytest.mark.asyncio
async def test_takeover_marks_resolver_disagreement_inconclusive(monkeypatch: pytest.MonkeyPatch) -> None:
    class DisagreeingResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, _hostname: str) -> TakeoverDNSOutcome:
            cname = 'bucket.s3.amazonaws.com' if self.nameserver == '1.1.1.1' else 'live.provider.example'
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=(cname,),
                terminal_rcode='NOERROR',
            )

        async def close(self) -> None:
            return None

    async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
        raise AssertionError('resolver disagreement must not trigger HTTP')

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', DisagreeingResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
    scanner = takeover.TakeoverScanner(
        ['bucket.example.test'],
        target='example.test',
        nameservers=['1.1.1.1', '8.8.8.8'],
    )

    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'inconclusive'
    assert outcome.error_types == ('ResolverDisagreementError',)
    assert scanner.inconclusive_count == 1
    assert scanner.stop_reason == 'incomplete-candidates'


@pytest.mark.asyncio
async def test_takeover_dns_only_rule_requires_terminal_rcode_agreement(monkeypatch: pytest.MonkeyPatch) -> None:
    class ConflictingRcodeResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, _hostname: str) -> TakeoverDNSOutcome:
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=('missing.elasticbeanstalk.com',),
                terminal_rcode='NXDOMAIN' if self.nameserver == '1.1.1.1' else 'NOERROR',
            )

        async def close(self) -> None:
            return None

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', ConflictingRcodeResolver)
    scanner = takeover.TakeoverScanner(
        ['app.example.test'],
        target='example.test',
        nameservers=['1.1.1.1', '8.8.8.8'],
    )

    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'inconclusive'
    assert outcome.error_types == ('ResolverDisagreementError',)
    assert outcome.indicators == ()


@pytest.mark.asyncio
async def test_takeover_wildcard_control_stays_in_the_authorized_zone(monkeypatch: pytest.MonkeyPatch) -> None:
    queried: list[str] = []

    class ScopedResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            queried.append(hostname)
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=() if hostname.startswith('takeover-control-') else ('bucket.s3.amazonaws.com',),
                terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NOERROR',
            )

        async def close(self) -> None:
            return None

    async def fake_fetch_text(_url: str, **_kwargs: object) -> FetcherResponse:
        return FetcherResponse(
            body='The specified bucket does not exist <BucketName>bucket</BucketName>',
            status=404,
            headers={},
        )

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', ScopedResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
    scanner = takeover.TakeoverScanner(
        ['example.co.uk'],
        target='example.co.uk',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    control = next(hostname for hostname in queried if hostname.startswith('takeover-control-'))
    assert control.endswith('.example.co.uk')
    assert (await scanner.get_takeover_outcomes())[0].status == 'indicator'


@pytest.mark.asyncio
async def test_takeover_distinguishes_nodata_from_required_nxdomain(monkeypatch: pytest.MonkeyPatch) -> None:
    class NodataResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, hostname: str) -> TakeoverDNSOutcome:
            return TakeoverDNSOutcome(
                resolver=self.nameserver,
                cname_chain=() if hostname.startswith('takeover-control-') else ('missing.elasticbeanstalk.com',),
                terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NODATA',
            )

        async def close(self) -> None:
            return None

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', NodataResolver)
    scanner = takeover.TakeoverScanner(
        ['app.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )

    await scanner.process()

    outcome = (await scanner.get_takeover_outcomes())[0]
    assert outcome.status == 'no-indicator'
    assert outcome.indicators == ()


def test_takeover_rule_language_validates_regex_and_all_classifications() -> None:
    rule = TakeoverRule(
        'regex-rule',
        'Regex Provider',
        (r'(?:^|\.)provider\.example$',),
        body_regex_all=(r'account\s+not\s+found',),
    )
    response = FetcherResponse(body='ACCOUNT  not   found', status=404, headers={})

    assert takeover._match_http(rule, response) == ('body-regex:account\\s+not\\s+found',)
    with pytest.raises(ValueError, match='invalid regular expression'):
        TakeoverRule('bad-regex', 'Bad Regex', ('[',), body_all=('missing',))
    with pytest.raises(ValueError, match='cannot mix DNS and HTTP predicates'):
        TakeoverRule(
            'mixed-rule',
            'Mixed Provider',
            (r'(?:^|\.)provider\.example$',),
            terminal_rcodes=('NXDOMAIN',),
            body_all=('missing',),
        )
    assert {rule.classification for rule in takeover.TAKEOVER_RULES} == {
        'vulnerable-indicator',
        'unverified-indicator',
        'edge-case',
    }


@pytest.mark.asyncio
async def test_takeover_dns_resolver_retains_partial_query_errors(monkeypatch: pytest.MonkeyPatch) -> None:
    class PartialResolver:
        def __init__(self, *, nameservers: list[str]) -> None:
            assert nameservers == ['1.1.1.1']

        async def query_dns(self, _hostname: str, record_type: str) -> object:
            if record_type == 'A':
                return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))])
            if record_type == 'AAAA':
                raise takeover.aiodns.error.DNSError(takeover.aiodns.error.ARES_ENODATA, 'no IPv6 data')
            raise takeover.aiodns.error.DNSError(takeover.aiodns.error.ARES_ETIMEOUT, 'CNAME timed out')

        async def close(self) -> None:
            return None

    monkeypatch.setattr(takeover.aiodns, 'DNSResolver', PartialResolver)

    resolver = takeover.TakeoverDNSResolver('1.1.1.1')
    outcome = await resolver.query('app.example.test')
    await resolver.close()

    assert outcome.terminal_rcode == 'NOERROR'
    assert outcome.error_type == 'DNSError'


def test_takeover_evidence_rejects_runtime_impossible_records() -> None:
    dns = {
        'resolver': '1.1.1.1',
        'cname_chain': ['bucket.s3.amazonaws.com'],
        'terminal_rcode': 'NOERROR',
    }
    base = {
        'status': 'no-indicator',
        'dns': [dns],
        'wildcard_dns': [],
        'http': [],
        'indicators': [],
        'error_types': [],
    }

    with pytest.raises(ValueError, match='one DNS outcome per resolver'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'dns': [dns, {**dns, 'terminal_rcode': 'NXDOMAIN'}],
            },
        )
    with pytest.raises(ValueError, match='one HTTP outcome per scheme'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'http': [
                    {'scheme': 'https', 'status': 404},
                    {'scheme': 'https', 'status': 503},
                ],
            },
        )
    with pytest.raises(ValueError, match='response status or error type'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'http': [{'scheme': 'https'}],
            },
        )
    with pytest.raises(ValueError, match='DNS ERROR outcomes require an error type'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'dns': [{**dns, 'terminal_rcode': 'ERROR'}],
            },
        )
    with pytest.raises(ValueError, match='include every DNS and HTTP outcome error'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'http': [{'scheme': 'https', 'error_type': 'TransportError'}],
                'wildcard_dns': [
                    {
                        'resolver': '1.1.1.1',
                        'cname_chain': [],
                        'terminal_rcode': 'NXDOMAIN',
                    }
                ],
            },
        )
    with pytest.raises(ValueError, match='wildcard control per resolver'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'http': [{'scheme': 'https', 'status': 404}],
            },
        )
    with pytest.raises(ValueError, match='candidate DNS agreement across resolvers'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'dns': [
                    dns,
                    {
                        'resolver': '8.8.8.8',
                        'cname_chain': ['different.s3.amazonaws.com'],
                        'terminal_rcode': 'NOERROR',
                    },
                ],
                'wildcard_dns': [
                    {
                        'resolver': '1.1.1.1',
                        'cname_chain': [],
                        'terminal_rcode': 'NXDOMAIN',
                    },
                    {
                        'resolver': '8.8.8.8',
                        'cname_chain': [],
                        'terminal_rcode': 'NXDOMAIN',
                    },
                ],
                'http': [{'scheme': 'https', 'status': 404}],
            },
        )
    with pytest.raises(ValueError, match='wildcard DNS agreement across resolvers'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'dns': [dns, {**dns, 'resolver': '8.8.8.8'}],
                'wildcard_dns': [
                    {
                        'resolver': '1.1.1.1',
                        'cname_chain': [],
                        'terminal_rcode': 'NXDOMAIN',
                    },
                    {
                        'resolver': '8.8.8.8',
                        'cname_chain': ['wildcard.s3.amazonaws.com'],
                        'terminal_rcode': 'NOERROR',
                    },
                ],
                'http': [{'scheme': 'https', 'status': 404}],
            },
        )
    with pytest.raises(ValueError, match='successful outcome for their matching scheme'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'status': 'indicator',
                'wildcard_dns': [
                    {
                        'resolver': '1.1.1.1',
                        'cname_chain': [],
                        'terminal_rcode': 'NXDOMAIN',
                    }
                ],
                'http': [{'scheme': 'http', 'status': 404}],
                'indicators': [
                    {
                        'classification': 'vulnerable-indicator',
                        'service': 'AWS/S3',
                        'rule_id': 'aws-s3',
                        'rule_revision': 'takeover-rules-v1',
                        'scheme': 'https',
                        'matched': ['body:BucketName'],
                    }
                ],
            },
        )
    with pytest.raises(ValueError, match='cannot mix DNS and HTTP predicates'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'status': 'indicator',
                'http': [{'scheme': 'https', 'status': 404}],
                'indicators': [
                    {
                        'classification': 'vulnerable-indicator',
                        'service': 'AWS/S3',
                        'rule_id': 'aws-s3',
                        'rule_revision': 'takeover-rules-v1',
                        'scheme': 'https',
                        'matched': ['body:BucketName', 'dns:terminal-rcode=NOERROR'],
                    }
                ],
            },
        )
    with pytest.raises(ValueError, match='wildcard control per resolver'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'status': 'indicator',
                'http': [{'scheme': 'https', 'status': 404}],
                'indicators': [
                    {
                        'classification': 'vulnerable-indicator',
                        'service': 'AWS/S3',
                        'rule_id': 'aws-s3',
                        'rule_revision': 'takeover-rules-v1',
                        'scheme': 'https',
                        'matched': ['body:BucketName'],
                    }
                ],
            },
        )
    wildcard = {
        'resolver': '1.1.1.1',
        'cname_chain': [],
        'terminal_rcode': 'NXDOMAIN',
    }
    with pytest.raises(ValueError, match='agree with every resolver outcome'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'status': 'indicator',
                'wildcard_dns': [wildcard],
                'indicators': [
                    {
                        'classification': 'vulnerable-indicator',
                        'service': 'AWS/Elastic Beanstalk',
                        'rule_id': 'aws-elastic-beanstalk',
                        'rule_revision': 'takeover-rules-v1',
                        'matched': ['dns:terminal-rcode=NXDOMAIN'],
                    }
                ],
            },
        )
    with pytest.raises(ValueError, match='status predicates must agree'):
        TakeoverCandidateOutcome.from_record(
            'bucket.example.test',
            {
                **base,
                'status': 'indicator',
                'wildcard_dns': [wildcard],
                'http': [{'scheme': 'https', 'status': 404}],
                'indicators': [
                    {
                        'classification': 'unverified-indicator',
                        'service': 'Helprace',
                        'rule_id': 'helprace',
                        'rule_revision': 'takeover-rules-v1',
                        'scheme': 'https',
                        'matched': ['status:301'],
                    }
                ],
            },
        )


@pytest.mark.asyncio
async def test_takeover_reports_http_session_close_failures_truthfully(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    class Resolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def close(self) -> None:
            return None

    class SharedSession:
        async def close(self) -> None:
            raise RuntimeError('close failed')

    async def fake_build_session(*_args: object, **_kwargs: object) -> SharedSession:
        return SharedSession()

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', Resolver)
    monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
    scanner = takeover.TakeoverScanner([], target='example.test', nameservers=['1.1.1.1'])

    await scanner.process()

    assert scanner.scan_error_type == 'RuntimeError'
    assert scanner.stop_reason == 'http-session-close-error'


@pytest.mark.asyncio
async def test_takeover_cancellation_closes_resolvers_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
    started = asyncio.Event()
    resolver_closed = asyncio.Event()
    session_close_started = asyncio.Event()
    allow_session_close = asyncio.Event()
    session_closed = asyncio.Event()

    class BlockingResolver:
        def __init__(self, nameserver: str) -> None:
            self.nameserver = nameserver

        async def query(self, _hostname: str) -> TakeoverDNSOutcome:
            started.set()
            await asyncio.Event().wait()
            raise AssertionError('unreachable')

        async def close(self) -> None:
            resolver_closed.set()

    class SharedSession:
        async def close(self) -> None:
            session_close_started.set()
            await allow_session_close.wait()
            session_closed.set()

    async def fake_build_session(*_args: object, **_kwargs: object) -> SharedSession:
        return SharedSession()

    monkeypatch.setattr(takeover, 'TakeoverDNSResolver', BlockingResolver)
    monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
    scanner = takeover.TakeoverScanner(
        ['app.example.test'],
        target='example.test',
        nameservers=['1.1.1.1'],
    )
    task = asyncio.create_task(scanner.process())
    await started.wait()

    task.cancel('operator-stop')
    await session_close_started.wait()
    task.cancel('later-stop')
    allow_session_close.set()
    with pytest.raises(asyncio.CancelledError, match='operator-stop'):
        await task

    assert resolver_closed.is_set()
    assert session_closed.is_set()
    assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('takeover-')]
