import os
import pathlib
import re
import textwrap

import pytest

import osbuild
import osbuild.meta


def create_runners(path, distro: str, base: str, versions):
    """Create runner"""
    runners = []

    basename = f"{distro}{base}"
    basepath = os.path.join(path, basename)
    with open(basepath, "x", encoding="utf-8") as f:
        f.write("#!/bin/bash")
        runners.append(basename)

    for x in versions:
        name = f"{distro}{x}"
        link = os.path.join(path, name)
        os.symlink(basepath, link)
        runners.append(name)

    return runners


def test_parse_name():
    table = {
        "arch": {
            "distro": "arch",
            "version": 0,
        },
        "fedora30": {
            "distro": "fedora",
            "version": 30,
        },
        "rhel7": {
            "distro": "rhel",
            "version": 7,
        },
        "ubuntu1804": {
            "distro": "ubuntu",
            "version": 1804,
        }
    }

    for name, want in table.items():
        d, v = osbuild.meta.RunnerInfo.parse_name(name)

        assert d == want["distro"]
        assert v == want["version"]


def test_runner_detection(tmp_path):

    runners = os.path.join(tmp_path, "runners")
    os.makedirs(runners)

    table = {
        "arch": {
            "base": "",
            "versions": [],
            "check": {"": 0},
            "fail": []
        },
        "fedora": {
            "base": 30,
            "versions": list(range(31, 40)),
            "check": {40: 39, 31: 31, 35: 35},
            "fail": [29],
        },
        "ubuntu": {
            "base": 1810,
            "versions": [1904, 1910, 2004],
            "check": {2010: 2004, 1912: 1910},
            "fail": [1804],
        },
        "rhel": {
            "base": 90,
            "versions": [91, 92, 93],
            "check": {94: 93},
        },
        "future": {
            "base": 100,
            "versions": [101, 102, 103],
            "check": {110: 103},
        }
    }

    want_all = []
    for distro, info in table.items():
        base = info["base"] or 0
        versions = info["versions"]
        want = create_runners(runners, distro, str(base), versions)
        meta = osbuild.meta.Index(tmp_path)
        have = meta.list_runners(distro)
        assert len(want) == len(have)
        want_all += want

        for v in [base] + versions:
            name = f"{distro}{v}"
            runner = meta.detect_runner(name)
            assert runner
            assert runner.distro == distro
            assert runner.version == v

        for v, t in info["check"].items():
            name = f"{distro}{v}"
            runner = meta.detect_runner(name)
            assert runner
            assert runner.distro == distro
            assert runner.version == t

        for v in info.get("fail", []):
            name = f"{distro}{v}"
            with pytest.raises(ValueError):
                runner = meta.detect_runner(name)

    have = meta.list_runners()
    assert len(have) == len(want_all)


def test_runner_sorting(tmp_path):

    runners = os.path.join(tmp_path, "runners")
    os.makedirs(runners)

    table = {
        "A": {
            "base": 1,
            "versions": [2, 3]
        },
        "B": {
            "base": 1,
            "versions": [2, 3]
        }
    }

    for distro, info in table.items():
        base = info["base"] or 0
        versions = info["versions"]
        create_runners(runners, distro, str(base), versions)

    meta = osbuild.meta.Index(tmp_path)
    have = meta.list_runners()

    names = [
        f"{i.distro}{i.version}" for i in have
    ]

    assert names == ["A1", "A2", "A3", "B1", "B2", "B3"]


def test_schema():
    schema = osbuild.meta.Schema(None)
    assert not schema

    schema = osbuild.meta.Schema({"type": "bool"})  # should be 'boolean'
    assert not schema.check().valid
    assert not schema

    schema = osbuild.meta.Schema({"type": "array", "minItems": 3})
    assert schema.check().valid
    assert schema

    res = schema.validate([1, 2])
    assert not res
    res = schema.validate([1, 2, 3])
    assert res


def make_fake_meta_json(tmp_path, name, version, invalid=""):
    meta_json_path = pathlib.Path(f"{tmp_path}/stages/{name}.meta.json")
    meta_json_path.parent.mkdir(exist_ok=True)
    if version == 1:
        schema_part = """
        "schema": {
          "properties": {
            "json_filename": {
              "type": "string"
            }
          }
        }
        """
    elif version == 2:
        schema_part = """
        "schema_2": {
          "json_devices": {
            "type": "object"
          }
        }
        """
    else:
        pytest.fail(f"unexpected schema version {version}")

    if invalid == "json":
        schema_part = """
        "xxx": {not-json}"'
        """
    elif invalid == "schema":
        schema_part = """
        "not": "following schema"
        """

    # pylint: disable=possibly-used-before-assignment
    meta_json_path.write_text("""
    {
      "summary": "some json summary",
      "description": [
        "long text",
        "with newlines"
      ],
      "capabilities": ["CAP_MAC_ADMIN", "CAP_BIG_MAC"],
      %s
    }
    """.replace("\n", " ") % schema_part, encoding="utf-8")
    return meta_json_path


def make_fake_py_module(tmp_path, name, invalid=""):
    py_path = pathlib.Path(f"{tmp_path}/stages/{name}")
    py_path.parent.mkdir(exist_ok=True)
    fake_py = '"""some py summary\nlong description\nwith newline"""'
    if invalid == "json":
        fake_py += textwrap.dedent("""
        SCHEMA = '"xxx": {not-json}"'
        """)
    elif invalid == "schema":
        fake_py += textwrap.dedent("""
        noT = "following schema"
        """)
    else:
        fake_py += textwrap.dedent("""
        SCHEMA = '"properties": {"py_filename":{"type": "string"}}'
        SCHEMA_2 = '"py_devices": {"type":"object"}'
        CAPABILITIES = ['CAP_MAC_ADMIN']
    """)
    py_path.write_text(fake_py, encoding="utf-8")


# keep the tests consistent between loading from python and json
def test_load_from_json(tmp_path):
    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=1)
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert modinfo.desc == "some json summary"
    assert modinfo.info == "long text\nwith newlines"
    assert modinfo.caps == set(["CAP_MAC_ADMIN", "CAP_BIG_MAC"])
    assert modinfo.opts == {
        "1": {"properties": {"json_filename": {"type": "string"}}},
        "2": {},
    }


def test_load_from_json_invalid_json(tmp_path):
    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2, invalid="json")
    with pytest.raises(SyntaxError) as exc:
        osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert str(exc.value) == "Invalid schema: Expecting property name enclosed in double quotes: line 2 column 17 (char 201)"


def test_load_from_json_invalid_schema(tmp_path, capsys):
    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2, invalid="schema")
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert modinfo is None
    assert re.match(r"WARNING: schema for .* is invalid: ", capsys.readouterr().err)


def test_load_from_py(tmp_path):
    make_fake_py_module(tmp_path, "org.osbuild.noop")
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert modinfo.desc == "some py summary"
    assert modinfo.info == "long description\nwith newline"
    assert modinfo.caps == set(["CAP_MAC_ADMIN"])
    assert modinfo.opts == {
        "1": {"properties": {"py_filename": {"type": "string"}}},
        "2": {"py_devices": {"type": "object"}},
    }


def test_load_from_py_invalid_json(tmp_path):
    make_fake_py_module(tmp_path, "org.osbuild.noop", invalid="json")
    with pytest.raises(SyntaxError) as exc:
        osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert str(exc.value) == "Invalid schema: Expecting property name enclosed in double quotes (org.osbuild.noop, line 4)"


def test_load_from_py_invalid_schema(tmp_path):
    make_fake_py_module(tmp_path, "org.osbuild.noop", invalid="schema")
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert modinfo.desc == "some py summary"
    assert modinfo.info == "long description\nwith newline"
    assert modinfo.opts == {
        "1": {},
        "2": {},
    }


def test_load_from_json_prefered(tmp_path):
    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2)
    make_fake_py_module(tmp_path, "org.osbuild.noop")
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    assert modinfo.desc == "some json summary"
    assert modinfo.info == "long text\nwith newlines"
    assert modinfo.caps == set(["CAP_MAC_ADMIN", "CAP_BIG_MAC"])
    assert modinfo.opts == {
        "1": {},
        "2": {"json_devices": {"type": "object"}},
    }


@pytest.mark.parametrize("test_input,expected_err", [
    # happy
    (
        {"summary": "some", "description": ["many", "strings"], "schema": {}}, ""), (
        {"summary": "some", "description": ["many", "strings"], "schema_2": {}}, ""), (
        {"summary": "some", "description": ["many", "strings"], "schema_2": {}, "capabilities": []}, ""), (
        {"summary": "some", "description": ["many", "strings"], "schema_2": {}, "capabilities": ["cap1", "cap2"]}, ""), (
        {"summary": "some", "description": ["many", "strings"], "schema": {}, "schema_2": {}, "capabilities": ["cap1", "cap2"]}, ""),
    # unhappy
    (
        # no description
        {"summary": "some", "schema": {}},
        "'description' is a required property",
    ), (
        # no summary
        {"description": ["yes"], "schema": {}},
        "'summary' is a required property",
    ), (
        # schema{,_2} missing
        {"summary": "some", "description": ["many", "strings"]},
        " is not valid",
    ), (
        # capabilities of wrong type
        {"summary": "some", "description": ["many", "strings"], "schema": {},
         "capabilities": [1, "cap1"]},
        " is not of type 'string'",
    ),
])
def test_meta_json_validation_schema(test_input, expected_err):
    schema = osbuild.meta.Schema(osbuild.meta.META_JSON_SCHEMA, "meta.json validator")
    res = schema.validate(test_input)
    errs = [e.as_dict() for e in res.errors]
    if expected_err:
        assert len(errs) == 1, f"unexpected error {errs}"
        assert expected_err in errs[0]["message"]
    else:
        assert not errs


@pytest.mark.parametrize("property_key, expected_value", [
    ("mounts", {"type": "array"}),
    ("devices", {"type": "object", "additionalProperties": True}),
])
def test_get_schema_automatically_added(tmp_path, property_key, expected_value):
    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2)
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
    json_schema = modinfo.get_schema()
    assert json_schema["properties"][property_key] == expected_value


@pytest.fixture(name="bootc_devices_mounts_dict")
def bootc_devices_mounts_dict_fixture() -> dict:
    """ bootc_devices_mounts_dict returns a dict with a typical bootc
    devices/mount dict
    """
    return {
        "devices": {
            "disk": {
                "type": "org.osbuild.loopback",
                "options": {
                    "filename": "disk.raw",
                    "partscan": True,
                }
            }
        },
        "mounts": [
            {
                "name": "root",
                "type": "org.osbuild.ext4",
                "source": "disk",
                "partition": 4,
                "target": "/"
            }, {
                "name": "ostree.deployment",
                "type": "org.osbuild.ostree.deployment",
                "options": {
                    "source": "mount",
                    "deployment": {
                        "default": True,
                    }
                }
            }
        ]
    }


@pytest.mark.parametrize("fmt_ver,name_or_type_key", [
    (1, "name"),
    (2, "type"),
])
def test_get_schema_devices_mounts_works(tmp_path, bootc_devices_mounts_dict, fmt_ver, name_or_type_key):
    stage_name = "org.osbuild.noop"

    fake_stage = bootc_devices_mounts_dict
    fake_stage[name_or_type_key] = stage_name

    make_fake_meta_json(tmp_path, stage_name, version=fmt_ver)
    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", stage_name)
    json_schema = modinfo.get_schema(version=str(fmt_ver))
    stage_schema = osbuild.meta.Schema(json_schema, stage_name)
    res = stage_schema.validate(fake_stage)
    assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
