import plistlib
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.apps import (
    App,
    AppsModule,
    app_source,
    app_version,
    cask_app_names,
    list_apps,
    render_markdown,
)
from osx_backup.modules.base import NullProgress
from osx_backup.runner import RecordingRunner


def _app(
    root: Path, name: str, version: str = "1.0", mas: bool = False, binary: bool = False
) -> Path:
    app = root / f"{name}.app"
    (app / "Contents").mkdir(parents=True)
    fmt = plistlib.FMT_BINARY if binary else plistlib.FMT_XML
    with (app / "Contents" / "Info.plist").open("wb") as fh:
        plistlib.dump({"CFBundleShortVersionString": version}, fh, fmt=fmt)
    if mas:
        (app / "Contents" / "_MASReceipt").mkdir()
    return app


def test_version_and_source(tmp_path: Path) -> None:
    a = _app(tmp_path, "Foo", "2.3", mas=True)
    assert app_version(a) == "2.3" and app_source(a) == "App Store"
    (tmp_path / "Bar.app").mkdir()
    assert app_version(tmp_path / "Bar.app") == "" and app_source(tmp_path / "Bar.app") == "manuel"
    setapp = _app(tmp_path / "Setapp", "Baz")
    assert app_source(setapp) == "Setapp"


def test_list_apps_skips_casks(tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Ghostty")
    _app(apps_dir, "Obsidian")
    _app(apps_dir / "Setapp", "CleanShot")
    caskroom = tmp_path / "Caskroom"
    _app(caskroom / "ghostty" / "1.0", "Ghostty")
    names = cask_app_names(caskroom)
    assert names == {"Ghostty.app"}
    apps = list_apps(apps_dir, names)
    assert [a.name for a in apps] == ["Obsidian", "CleanShot"]
    assert apps[1].source == "Setapp"


def test_render_markdown() -> None:
    md = render_markdown([App("Obsidian", "1.5", "manuel")])
    assert "| Obsidian | 1.5 | manuel |" in md


def test_module_backup_and_restore(ctx: Context, tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Obsidian")
    ctx.applications_dir = apps_dir
    r = RecordingRunner()
    r.add(("brew", "--caskroom"), stdout=str(tmp_path / "Caskroom") + "\n")
    ctx.runner = r
    m = AppsModule()
    assert m.inventory(ctx).items == 1
    assert m.backup(ctx, NullProgress()).status == "ok"
    md = ctx.dest / "apps" / "applications.md"
    assert "Obsidian" in md.read_text()
    res = m.restore(ctx, NullProgress())
    assert res.status == "ok"
    assert "Obsidian" in ctx.console.export_text()


def test_module_unavailable(ctx: Context, tmp_path: Path) -> None:
    ctx.applications_dir = tmp_path / "nope"
    assert not AppsModule().inventory(ctx).available


def test_version_binary_plist(tmp_path: Path) -> None:
    assert app_version(_app(tmp_path, "Bin", "9.9", binary=True)) == "9.9"


def test_version_broken_plists(tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Good", "3.1")
    truncated = _app(apps_dir, "Trunc")
    plist = truncated / "Contents" / "Info.plist"
    plist.write_bytes(plist.read_bytes()[:40])
    non_dict = apps_dir / "NonDict.app"
    (non_dict / "Contents").mkdir(parents=True)
    with (non_dict / "Contents" / "Info.plist").open("wb") as fh:
        plistlib.dump(["pas un dict"], fh)
    assert app_version(truncated) == ""
    assert app_version(non_dict) == ""
    apps = list_apps(apps_dir, set())
    assert [(a.name, a.version) for a in apps] == [
        ("Good", "3.1"),
        ("NonDict", ""),
        ("Trunc", ""),
    ]


def test_render_markdown_escapes_pipes() -> None:
    md = render_markdown([App("A|B", "1|2", "manuel")])
    assert "| A\\|B | 1\\|2 | manuel |" in md


def test_module_warns_on_unreadable_bundle(ctx: Context, tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Obsidian")
    broken = _app(apps_dir, "Broken")
    (broken / "Contents" / "Info.plist").write_bytes(b"<?xml version")
    ctx.applications_dir = apps_dir
    r = RecordingRunner()
    r.add(("brew", "--caskroom"), stdout=str(tmp_path / "Caskroom") + "\n")
    ctx.runner = r
    m = AppsModule()
    inv = m.inventory(ctx)
    assert inv.items == 2
    assert inv.warnings == ["version illisible : Broken"]
    res = m.backup(ctx, NullProgress())
    assert res.status == "ok"
    assert res.warnings == ["version illisible : Broken"]
    text = (ctx.dest / "apps" / "applications.md").read_text()
    assert "| Broken |  | manuel |" in text and "Obsidian" in text
    assert "version illisible : Broken" in ctx.console.export_text()


def test_inventory_does_not_print_warnings(ctx: Context, tmp_path: Path) -> None:
    """L'inventaire reste muet : `map` affiche déjà les avertissements dans sa table."""
    apps_dir = tmp_path / "Applications"
    broken = _app(apps_dir, "Broken")
    (broken / "Contents" / "Info.plist").write_bytes(b"<?xml version")
    ctx.applications_dir = apps_dir
    r = RecordingRunner()
    r.add(("brew", "--caskroom"), stdout=str(tmp_path / "Caskroom") + "\n")
    ctx.runner = r
    inv = AppsModule().inventory(ctx)
    assert inv.warnings == ["version illisible : Broken"]
    assert ctx.console.export_text() == ""
