#!/usr/bin/python3

import hashlib
import pathlib
import platform
import re
import shutil
import textwrap
from unittest.mock import patch

import pytest

import osbuild.testutil
from osbuild.testutil.net import http_serve_directory, https_serve_directory, https_serve_directory_mtls

SOURCES_NAME = "org.osbuild.curl"


def test_curl_source_not_exists(tmp_path, sources_service):
    desc = {
        "url": "http://localhost:80/a",
    }
    checksum = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
    sources_service.cache = tmp_path
    assert not sources_service.exists(checksum, desc)


def test_curl_source_exists(tmp_path, sources_service):
    desc = {
        "url": "http://localhost:80/a",
    }
    checksum = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
    sources_service.cache = tmp_path
    (sources_service.cache / checksum).touch()
    assert sources_service.exists(checksum, desc)


def test_curl_source_amend_secrets(monkeypatch, sources_service):
    desc = {
        "url": "http://localhost:80/a",
        "secrets": {
            "name": "org.osbuild.mtls",
        },
    }

    monkeypatch.setenv("OSBUILD_SOURCES_CURL_SSL_CLIENT_KEY", "key")
    monkeypatch.setenv("OSBUILD_SOURCES_CURL_SSL_CLIENT_CERT", "cert")
    checksum = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
    _, new_desc = sources_service.amend_secrets(checksum, desc)
    assert new_desc["secrets"]["ssl_client_key"] == "key"
    assert new_desc["secrets"]["ssl_client_cert"] == "cert"
    assert new_desc["secrets"]["ssl_ca_cert"] is None


def test_curl_source_amend_secrets_fail(sources_service):
    desc = {
        "url": "http://localhost:80/a",
        "secrets": {
            "name": "org.osbuild.mtls",
        },
    }
    checksum = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
    with pytest.raises(RuntimeError) as exc:
        sources_service.amend_secrets(checksum, desc)
    assert "mtls secrets required" in str(exc)


class FakeSubscriptionManager:
    def get_secrets(self, url):
        return f"secret-for-{url}"


def test_curl_source_amend_secrets_subscription_mgr(sources_service):
    desc = {
        "url": "http://localhost:80/a",
        "secrets": {
            "name": "org.osbuild.rhsm",
        },
    }

    sources_service.subscriptions = FakeSubscriptionManager()
    checksum = "sha256:1234567890123456789012345678901234567890909b14ffb032aa20fa23d9ad6"
    checksum, desc = sources_service.amend_secrets(checksum, desc)
    assert desc["secrets"] == "secret-for-['http://localhost:80/a']"


@pytest.fixture(name="curl_parallel")
def curl_parallel_fixture(sources_module, sources_service, request):
    use_parallel = request.param
    if use_parallel and not sources_module.curl_has_parallel_downloads:
        pytest.skip("system curl does not support parallel downloads")
    sources_service._curl_has_parallel_downloads = use_parallel  # pylint: disable=protected-access
    yield sources_service


@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
def test_curl_download_many_fail(curl_parallel):
    TEST_SOURCES = {
        "sha:1111111111111111111111111111111111111111111111111111111111111111": {
            "url": "http://localhost:9876/random-not-exists",
        },
    }
    with pytest.raises(RuntimeError) as exp:
        curl_parallel.fetch_all(TEST_SOURCES)
    assert str(exp.value).startswith("curl: error downloading http://localhost:9876/random-not-exists")
    assert 'http://localhost:9876/random-not-exists: error code 7' in str(exp.value)


def make_test_sources(fake_httpd_root, port, n_files, start_n=0, cacert="", secret_name=""):
    """
    Create test sources for n_file. All files have the names
    0,1,2...
    and the content that matches their name (i.e. file "0" has content "0")

    Returns a sources dict that can be used as input for "fetch_all()" with
    the correct hash/urls.
    """
    proto = "https" if cacert else "http"

    fake_httpd_root.mkdir(exist_ok=True)
    sources = {}
    for i in range(start_n, start_n + n_files):
        name = f"{i}"
        key = f"sha256:{hashlib.sha256(name.encode()).hexdigest()}"
        val = {
            "url": f"{proto}://localhost:{port}/{name}",
        }
        if cacert:
            val["secrets"] = {}
            if secret_name != "":
                val["secrets"]["name"] = secret_name
            val["secrets"]["ssl_ca_cert"] = cacert
        sources[key] = val
        (fake_httpd_root / name).write_text(name, encoding="utf8")

    return sources


@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
def test_curl_download_many_with_retry(tmp_path, curl_parallel):
    fake_httpd_root = tmp_path / "fake-httpd-root"

    simulate_failures = 2
    with http_serve_directory(fake_httpd_root, simulate_failures=simulate_failures) as httpd:
        test_sources = make_test_sources(fake_httpd_root, httpd.server_port, 5)

        curl_parallel.cache = tmp_path / "curl-download-dir"
        curl_parallel.cache.mkdir()
        curl_parallel.fetch_all(test_sources)
        # we simulated N failures and we need to fetch K files
        assert httpd.reqs.count == simulate_failures + len(test_sources)
    # double downloads happend in the expected format
    for chksum in test_sources:
        assert (curl_parallel.cache / chksum).exists()


@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
def test_curl_download_many_chksum_validate(tmp_path, curl_parallel):
    fake_httpd_root = tmp_path / "fake-httpd-root"

    with http_serve_directory(fake_httpd_root) as httpd:
        test_sources = make_test_sources(fake_httpd_root, httpd.server_port, 5)
        # "break" the hash of file "1" by replacing the content to no longer
        # match the checksum
        (fake_httpd_root / "1").write_text("hash-no-longer-matches", encoding="utf8")

        curl_parallel.cache = tmp_path / "curl-download-dir"
        curl_parallel.cache.mkdir()
        with pytest.raises(RuntimeError) as exp:
            curl_parallel.fetch_all(test_sources)
        assert re.search(r"checksum mismatch: sha256:.* http://localhost:.*/1", str(exp.value))


@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
def test_curl_download_many_retries(tmp_path, curl_parallel):
    fake_httpd_root = tmp_path / "fake-httpd-root"

    with http_serve_directory(fake_httpd_root) as httpd:
        test_sources = make_test_sources(fake_httpd_root, httpd.server_port, 5)
        # remove all the sources
        shutil.rmtree(fake_httpd_root)

        curl_parallel.cache = tmp_path / "curl-download-dir"
        curl_parallel.cache.mkdir()
        with pytest.raises(RuntimeError) as exp:
            curl_parallel.fetch_all(test_sources)
        # curl will retry 10 times
        assert httpd.reqs.count == 10 * len(test_sources)
        assert "curl: error downloading http://localhost:" in str(exp.value)


def test_curl_user_agent(tmp_path, sources_module):
    config_path = tmp_path / "curl-config.txt"
    test_sources = make_test_sources(tmp_path, 80, 2)

    sources_module.gen_curl_download_config(config_path, test_sources.items())
    assert config_path.exists()
    assert f'user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"' in config_path.read_text()


@pytest.mark.parametrize("with_proxy", [True, False])
def test_curl_download_proxy(tmp_path, monkeypatch, sources_module, with_proxy):
    config_path = tmp_path / "curl-config.txt"
    test_sources = make_test_sources(tmp_path, 80, 2)

    if with_proxy:
        monkeypatch.setenv("OSBUILD_SOURCES_CURL_PROXY", "http://my-proxy")
    sources_module.gen_curl_download_config(config_path, test_sources.items())
    assert config_path.exists()
    if with_proxy:
        assert 'proxy = "http://my-proxy"\n' in config_path.read_text()
    else:
        assert "proxy" not in config_path.read_text()


TEST_SOURCE_PAIRS_GEN_DOWNLOAD_CONFIG = [
    (
        # sha256("0")
        "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9",
        {
            "url": "http://example.com/file/0",
        },
    ), (
        # sha256("1")
        "sha256:6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
        {
            "url": "http://example.com/file/1",
            "insecure": True,
        },
    ), (
        # sha256("2")
        "sha256:d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
        {
            "url": "http://example.com/file/2",
            "secrets": {
                "ssl_ca_cert": "some-ssl_ca_cert",
            },
        },
    ), (
        # sha256("3")
        "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce",
        {
            "url": "http://example.com/file/3",
            "secrets": {
                "ssl_client_cert": "some-ssl_client_cert",
                "ssl_client_key": "some-ssl_client_key",
            },
        },
    ),
]


def test_curl_gen_download_config_old_curl(tmp_path, sources_module):
    config_path = tmp_path / "curl-config.txt"
    sources_module.gen_curl_download_config(config_path, [(
        # sha256("0")
        "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9",
        {
            "url": "http://example.com/file/0",
        },
    )])

    assert config_path.exists()
    assert config_path.read_text(encoding="utf8") == textwrap.dedent(f"""\
    # per-url options
    url = "http://example.com/file/0"
    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
    silent
    speed-limit = 1000
    connect-timeout = 30
    fail
    location
    no-insecure
    """)


def test_curl_gen_download_config_parallel(tmp_path, sources_module):
    config_path = tmp_path / "curl-config.txt"
    sources_module.gen_curl_download_config(config_path, TEST_SOURCE_PAIRS_GEN_DOWNLOAD_CONFIG, parallel=True)

    assert config_path.exists()
    assert config_path.read_text(encoding="utf8") == textwrap.dedent(f"""\
    # global options
    parallel

    # per-url options
    url = "http://example.com/file/0"
    output = "sha256:5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9"
    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
    silent
    speed-limit = 1000
    connect-timeout = 30
    fail
    location
    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
    no-insecure
    next

    url = "http://example.com/file/1"
    output = "sha256:6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
    silent
    speed-limit = 1000
    connect-timeout = 30
    fail
    location
    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
    insecure
    next

    url = "http://example.com/file/2"
    output = "sha256:d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35"
    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
    silent
    speed-limit = 1000
    connect-timeout = 30
    fail
    location
    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
    cacert = "some-ssl_ca_cert"
    no-insecure
    next

    url = "http://example.com/file/3"
    output = "sha256:4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce"
    user-agent = "osbuild (Linux.{platform.machine()}; https://osbuild.org/)"
    silent
    speed-limit = 1000
    connect-timeout = 30
    fail
    location
    write-out = "{sources_module.CURL_WRITE_OUT_FMT}"
    cert = "some-ssl_client_cert"
    key = "some-ssl_client_key"
    no-insecure
    """)


# fc39
NEW_CURL_OUTPUT = """\
curl 8.2.1 (x86_64-redhat-linux-gnu) libcurl/8.2.1 OpenSSL/3.1.1 zlib/1.2.13 libidn2/2.3.7 nghttp2/1.55.1
Release-Date: 2023-07-26
Protocols: file ftp ftps http https
Features: alt-svc AsynchDNS GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz SPNEGO SSL threadsafe UnixSockets
"""

# centos-stream8
OLD_CURL_OUTPUT = """\
curl 7.61.1 (x86_64-redhat-linux-gnu) libcurl/7.61.1 OpenSSL/1.1.1k zlib/1.2.11 nghttp2/1.33.0
Release-Date: 2018-09-05
Protocols: dict file ftp ftps gopher http https imap imaps pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: AsynchDNS IPv6 Largefile GSS-API Kerberos SPNEGO NTLM NTLM_WB SSL libz TLS-SRP HTTP2 UnixSockets HTTPS-proxy
"""


@patch("subprocess.check_output")
def test_curl_has_parallel_download(mocked_check_output, sources_module):
    mocked_check_output.return_value = NEW_CURL_OUTPUT
    assert sources_module.curl_has_parallel_downloads()

    mocked_check_output.return_value = OLD_CURL_OUTPUT
    assert not sources_module.curl_has_parallel_downloads()


# this check is only done in the "parallel=True" case
@pytest.mark.parametrize("curl_parallel", [True], indirect=["curl_parallel"])
def test_curl_result_is_double_checked(tmp_path, curl_parallel):
    test_sources = make_test_sources(tmp_path, 1234, 5)

    # simulate that curl returned an exit code 0 even though not all
    # sources got downloaded
    with osbuild.testutil.mock_command("curl", ""):
        with pytest.raises(RuntimeError) as exp:
            curl_parallel.fetch_all(test_sources)
        assert re.match(r"curl: finished with return_code 0 but .* left to download", str(exp.value))


@pytest.mark.parametrize("curl_parallel", [True, False], indirect=["curl_parallel"])
def test_curl_download_many_mixed_certs(tmp_path, monkeypatch, sources_module, curl_parallel):
    monkeypatch.setenv("OSBUILD_SOURCES_CURL_USE_PARALLEL", "1")
    # ensure this does not accidentaly succeeds because we retry
    monkeypatch.setattr(sources_module, "NR_RETRYS", 1)

    fake_httpd_root = tmp_path / "fake-httpd-root"
    git_root = pathlib.Path(__file__).parent.parent.parent
    cert1_path = git_root / "test/data/certs/cert1.pem"
    key1_path = git_root / "test/data/certs/key1.pem"
    cert2_path = git_root / "test/data/certs/cert2.pem"
    key2_path = git_root / "test/data/certs/key2.pem"

    with https_serve_directory(fake_httpd_root, certfile=cert1_path, keyfile=key1_path) as httpds:
        with https_serve_directory(fake_httpd_root, certfile=cert2_path, keyfile=key2_path) as httpds2:
            test_sources = make_test_sources(
                fake_httpd_root, httpds.server_port, 2, start_n=10, cacert=cert1_path)
            test_sources.update(make_test_sources(
                fake_httpd_root, httpds2.server_port, 2, cacert=cert2_path))
            assert len(test_sources) == 4

            curl_parallel.cache = tmp_path / "curl-download-dir"
            curl_parallel.cache.mkdir()
            curl_parallel.fetch_all(test_sources)

            assert httpds.reqs.count == 2
            assert httpds2.reqs.count == 2


def test_curl_download_mtls(tmp_path, monkeypatch, sources_service):
    fake_httpd_root = tmp_path / "fake-httpd-root"
    cert_dir = pathlib.Path(__file__).parent.parent.parent / "test/data/certs"
    cacert = cert_dir / "ca/cert.pem"
    assert cacert.exists()
    servercert = cert_dir / "server/cert.pem"
    assert servercert.exists()
    serverkey = cert_dir / "server/key.pem"
    assert serverkey.exists()
    clientcert = cert_dir / "client/cert.pem"
    assert clientcert.exists()
    clientkey = cert_dir / "client/key.pem"
    assert clientkey.exists()

    monkeypatch.setenv("OSBUILD_SOURCES_CURL_SSL_CA_CERT", cacert.as_posix())
    monkeypatch.setenv("OSBUILD_SOURCES_CURL_SSL_CLIENT_CERT", clientcert.as_posix())
    monkeypatch.setenv("OSBUILD_SOURCES_CURL_SSL_CLIENT_KEY", clientkey.as_posix())

    with https_serve_directory_mtls(fake_httpd_root, ca_cert=cacert,
                                    server_cert=servercert, server_key=serverkey) as httpds:
        test_sources = make_test_sources(
            fake_httpd_root, httpds.server_port, 1, cacert=cacert, secret_name="org.osbuild.mtls")

        sources_service.cache = tmp_path / "curl-download-dir"
        sources_service.cache.mkdir()
        sources_service.fetch_all(test_sources)

        assert httpds.reqs.count == 1
