import dataclasses
import os.path
import textwrap
from typing import Tuple, List, Optional, Union

import pytest

from debputy.filesystem_scan import PathDef, build_virtual_fs
from debputy.highlevel_manifest_parser import YAMLManifestParser
from debputy.intermediate_manifest import PathType, IntermediateManifest, TarMember
from debputy.plugin.api.test_api import virtual_path
from debputy.transformation_rules import TransformationRuntimeError


@pytest.fixture()
def manifest_parser_pkg_foo(
    amd64_dpkg_architecture_variables,
    dpkg_arch_query,
    package_single_foo_arch_all_cxt_amd64,
    null_substitution,
    no_profiles_or_build_options,
    debputy_plugin_feature_set,
) -> YAMLManifestParser:
    # We just need an empty directory really.  The "data" dir is "empty enough" to avoid picking up
    # any packager provided files
    debian_dir = os.path.join(os.path.dirname(__file__), "data")
    return YAMLManifestParser(
        "debian/test-debputy.manifest",
        package_single_foo_arch_all_cxt_amd64,
        null_substitution,
        amd64_dpkg_architecture_variables,
        dpkg_arch_query,
        no_profiles_or_build_options,
        debputy_plugin_feature_set,
        debian_dir=debian_dir,
    )


@dataclasses.dataclass(slots=True, kw_only=True)
class Expected:
    mtime: int
    mode: Optional[int] = None
    link_target: Optional[str] = None
    owner: str = "root"
    group: str = "root"
    has_fs_path: bool = True


def _show_name_on_error(_: str) -> bool:
    return False


def _has_fs_path(tm: TarMember) -> bool:
    return tm.fs_path is not None


def verify_paths(
    intermediate_manifest: IntermediateManifest,
    expected_results: List[Tuple[Union[str, PathDef], Expected]],
) -> None:
    result = {tm.member_path: tm for tm in intermediate_manifest}
    expected_table = {
        f"./{p}" if isinstance(p, str) else f"./{p.path_name}": e
        for p, e in expected_results
    }

    for path_name, expected in expected_table.items():
        tm = result[path_name]
        if tm.path_type == PathType.SYMLINK:
            assert tm.link_target == expected.link_target or _show_name_on_error(
                path_name
            )
        else:
            assert tm.link_target == "" or _show_name_on_error(path_name)
        if expected.mode is not None:
            assert oct(tm.mode) == oct(expected.mode) or _show_name_on_error(path_name)
        if expected.mtime is not None:
            assert tm.mtime == expected.mtime or _show_name_on_error(path_name)
        assert tm.owner == expected.owner or _show_name_on_error(path_name)
        assert tm.group == expected.group or _show_name_on_error(path_name)
        assert _has_fs_path(tm) == expected.has_fs_path or _show_name_on_error(
            path_name
        )

    del result["./"]
    if len(result) != len(expected_results):
        for tm in result.values():
            assert tm.member_path in expected_table


def test_mtime_clamp_and_builtin_dir_mode(manifest_parser_pkg_foo):
    manifest = manifest_parser_pkg_foo.build_manifest()
    claim_mtime_to = 255
    path_defs: List[Tuple[PathDef, Expected]] = [
        (
            virtual_path("usr/", mode=0o700, mtime=10, fs_path="/nowhere/usr/"),
            Expected(mode=0o755, mtime=10),
        ),
        (
            virtual_path(
                "usr/bin/", mode=0o2534, mtime=5000, fs_path="/nowhere/usr/bin/"
            ),
            Expected(mode=0o755, mtime=claim_mtime_to),
        ),
        (
            virtual_path(
                "usr/bin/my-exec",
                mtime=5000,
                fs_path="/nowhere/usr/bin/my-exec",
                link_target="../../some/where/else",
            ),
            # Implementation detail; symlinks do not refer to their FS path in the intermediate manifest.
            Expected(
                mtime=claim_mtime_to, link_target="/some/where/else", has_fs_path=False
            ),
        ),
    ]

    fs_root = build_virtual_fs([d[0] for d in path_defs], read_write_fs=True)

    assert [p.name for p in manifest.all_packages] == ["foo"]

    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        "foo", fs_root, claim_mtime_to
    )
    verify_paths(intermediate_manifest, path_defs)


def test_create_symlinks(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            create-symlinks:
                - path: 'usr/bin/my-exec'
                  target: '../../some/where/else'
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    fs_root = build_virtual_fs(["./"], read_write_fs=True)
    expected_results = [
        ("usr/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        ("usr/bin/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        (
            "usr/bin/my-exec",
            Expected(
                mtime=claim_mtime_to, link_target="/some/where/else", has_fs_path=False
            ),
        ),
    ]

    assert [p.name for p in manifest.all_packages] == ["foo"]

    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        "foo", fs_root, claim_mtime_to
    )
    verify_paths(intermediate_manifest, expected_results)


def test_create_directories(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            directories:
            - path: some/empty/directory
              mode: "0700"
            - another/empty/directory
            - path: a/third-empty/directory
              owner: www-data
              group: www-data
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    paths = [
        virtual_path("some/", mode=0o700, mtime=10, fs_path="/nowhere/some"),
        virtual_path(
            "some/empty/", mode=0o700, mtime=10, fs_path="/nowhere/some/empty"
        ),
        virtual_path(
            "some/empty/directory",
            mode=0o755,
            mtime=10,
            fs_path="/nowhere/some/empty/directory",
        ),
    ]
    fs_root = build_virtual_fs(paths, read_write_fs=True)
    expected_results = [
        # Implementation note that may change: implicit directories are currently always without an fs path
        ("some/", Expected(mode=0o700, mtime=10, has_fs_path=False)),
        ("some/empty/", Expected(mode=0o700, mtime=10, has_fs_path=False)),
        # Test note: mtime is for 'some/empty/directory/' could realistic be either 10 (original) or 255 (clamp)
        # depending on how we view this corner case.
        (
            "some/empty/directory/",
            Expected(mode=0o700, mtime=claim_mtime_to, has_fs_path=False),
        ),
        ("another/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        (
            "another/empty/",
            Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False),
        ),
        (
            "another/empty/directory/",
            Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False),
        ),
        ("a/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        (
            "a/third-empty/",
            Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False),
        ),
        (
            "a/third-empty/directory/",
            Expected(
                mode=0o755,
                mtime=claim_mtime_to,
                owner="www-data",
                group="www-data",
                has_fs_path=False,
            ),
        ),
    ]

    assert [p.name for p in manifest.all_packages] == ["foo"]

    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        "foo", fs_root, claim_mtime_to
    )
    verify_paths(intermediate_manifest, expected_results)


def test_transformation_exclude(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            transformations:
            - exclude: some/empty
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    paths = [
        virtual_path("some/", mode=0o700, mtime=10, fs_path="/nowhere/some"),
        virtual_path(
            "some/empty/", mode=0o700, mtime=10, fs_path="/nowhere/some/empty"
        ),
        virtual_path(
            "some/empty/directory",
            mode=0o755,
            mtime=10,
            fs_path="/nowhere/some/empty/directory",
        ),
    ]
    fs_root = build_virtual_fs(paths, read_write_fs=True)
    expected_results = [
        ("some/", Expected(mode=0o755, mtime=10)),
    ]

    assert [p.name for p in manifest.all_packages] == ["foo"]

    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        "foo", fs_root, claim_mtime_to
    )

    verify_paths(intermediate_manifest, expected_results)


def test_transformation_exclude_no_match(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            transformations:
            - exclude: some/non-existing-path
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    paths = [
        virtual_path("some/", mode=0o700, mtime=10, fs_path="/nowhere/some"),
        virtual_path(
            "some/empty/", mode=0o700, mtime=10, fs_path="/nowhere/some/empty"
        ),
        virtual_path(
            "some/empty/directory",
            mode=0o755,
            mtime=10,
            fs_path="/nowhere/some/empty/directory",
        ),
    ]
    fs_root = build_virtual_fs(paths, read_write_fs=True)
    assert [p.name for p in manifest.all_packages] == ["foo"]

    with pytest.raises(TransformationRuntimeError) as e_info:
        manifest.apply_to_binary_staging_directory("foo", fs_root, claim_mtime_to)
    expected = (
        'The match rule "./some/non-existing-path" in transformation'
        ' "packages.foo.transformations[0].exclude <some/non-existing-path>" did not match any paths. Either'
        " the definition is redundant (and can be omitted) or the match rule is incorrect."
    )
    assert expected == e_info.value.args[0]


def test_transformation_move_basic(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            transformations:
            - move:
                source: some/dir
                target: new/dir/where-else
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    paths = [
        virtual_path("some/", mode=0o700, mtime=10, fs_path="/nowhere/some"),
        virtual_path("some/dir/", mode=0o700, mtime=10, fs_path="/nowhere/some/empty"),
        virtual_path(
            "some/dir/some-dir-symlink1", mtime=10, link_target="/abs/some-target1"
        ),
        virtual_path(
            "some/dir/some-dir-symlink2", mtime=10, link_target="../some-target2"
        ),
        virtual_path(
            "some/dir/some-dir-symlink3",
            mtime=10,
            link_target="/new/dir/where-else/some-target3",
        ),
    ]
    fs_root = build_virtual_fs(paths, read_write_fs=True)
    assert [p.name for p in manifest.all_packages] == ["foo"]

    expected_results = [
        ("some/", Expected(mode=0o755, mtime=10)),
        ("new/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        ("new/dir/", Expected(mode=0o755, mtime=claim_mtime_to, has_fs_path=False)),
        ("new/dir/where-else/", Expected(mode=0o755, mtime=10)),
        # FIXME: should be 10
        (
            "new/dir/where-else/some-dir-symlink1",
            Expected(mtime=None, link_target="/abs/some-target1", has_fs_path=False),
        ),
        (
            "new/dir/where-else/some-dir-symlink2",
            Expected(mtime=None, link_target="../some-target2", has_fs_path=False),
        ),
        (
            "new/dir/where-else/some-dir-symlink3",
            Expected(mtime=None, link_target="some-target3", has_fs_path=False),
        ),
    ]
    assert [p.name for p in manifest.all_packages] == ["foo"]

    intermediate_manifest = manifest.apply_to_binary_staging_directory(
        "foo", fs_root, claim_mtime_to
    )

    print(intermediate_manifest)

    verify_paths(intermediate_manifest, expected_results)


def test_transformation_move_no_match(manifest_parser_pkg_foo):
    content = textwrap.dedent(
        """\
    manifest-version: '0.1'
    packages:
        foo:
            transformations:
            - move:
                source: some/non-existing-path
                target: some/where-else
    """
    )
    manifest = manifest_parser_pkg_foo.parse_manifest(fd=content)
    claim_mtime_to = 255
    paths = [
        virtual_path("some/", mode=0o700, mtime=10, fs_path="/nowhere/some"),
        virtual_path(
            "some/empty/", mode=0o700, mtime=10, fs_path="/nowhere/some/empty"
        ),
        virtual_path(
            "some/empty/directory",
            mode=0o755,
            mtime=10,
            fs_path="/nowhere/some/empty/directory",
        ),
    ]
    fs_root = build_virtual_fs(paths, read_write_fs=True)
    assert [p.name for p in manifest.all_packages] == ["foo"]

    with pytest.raises(TransformationRuntimeError) as e_info:
        manifest.apply_to_binary_staging_directory("foo", fs_root, claim_mtime_to)
    expected = (
        'The match rule "./some/non-existing-path" in transformation'
        ' "packages.foo.transformations[0].move <some/non-existing-path>" did not match any paths. Either'
        " the definition is redundant (and can be omitted) or the match rule is incorrect."
    )
    assert expected == e_info.value.args[0]
