"""Audio configuration backend — config file and PipeWire management."""

from __future__ import annotations

import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

from i18n import _

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

VALID_BUFFER_SIZES = (16, 32, 64, 128, 256, 512, 1024, 2048, 4096)
VALID_SAMPLE_RATES = (22050, 32000, 44100, 48000, 88200, 96000, 192000)

JACK_BOOL_PARAMS = (
    "jack.show-monitor",
    "jack.merge-monitor",
    "jack.show-midi",
    "jack.short-name",
)

SELF_CONNECT_MODES = (
    "allow",
    "fail-external",
    "ignore-external",
    "fail-all",
    "ignore-all",
)

SELF_CONNECT_DESCRIPTIONS = {
    "allow": _("Don't restrict self connect requests (default)"),
    "fail-external": _("Fail self connect requests to external ports only"),
    "ignore-external": _("Ignore self connect requests to external ports only"),
    "fail-all": _("Fail all self connect requests"),
    "ignore-all": _("Ignore all self connect requests"),
}

JACK_PARAM_DESCRIPTIONS = {
    "jack.show-monitor": _("Show the Monitor client and its ports."),
    "jack.merge-monitor": _("Exposes the capture ports and monitor ports on the same JACK device client."),
    "jack.show-midi": _("Show the MIDI clients and their ports."),
    "jack.short-name": _("Use shorter names for the device client name."),
}

ICON_PATH = "/usr/share/icons/hicolor/scalable/apps/ubuntustudio-audio-config.svg"

APP_TITLE = _("Ubuntu Studio Audio Configuration")

# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class JackConfig:
    """Parsed JACK configuration from ubuntustudio.conf."""

    buffer_size: int = 1024
    sample_rate: int = 48000
    show_monitor: bool = True
    merge_monitor: bool = True
    show_midi: bool = True
    short_name: bool = True
    self_connect_mode: str = "allow"

    @property
    def bool_params(self) -> dict[str, bool]:
        return {
            "jack.show-monitor": self.show_monitor,
            "jack.merge-monitor": self.merge_monitor,
            "jack.show-midi": self.show_midi,
            "jack.short-name": self.short_name,
        }

    @bool_params.setter
    def bool_params(self, mapping: dict[str, bool]) -> None:
        self.show_monitor = mapping.get("jack.show-monitor", self.show_monitor)
        self.merge_monitor = mapping.get("jack.merge-monitor", self.merge_monitor)
        self.show_midi = mapping.get("jack.show-midi", self.show_midi)
        self.short_name = mapping.get("jack.short-name", self.short_name)


@dataclass
class BootParams:
    """Kernel boot parameters."""

    preempt_full: bool = False
    threadirqs: bool = False
    rcu_nocbs_all: bool = False
    nohz_full_all: bool = False

    PARAM_MAP: dict[str, str] = field(
        default_factory=lambda: {
            "preempt=full": "preempt_full",
            "threadirqs": "threadirqs",
            "rcu_nocbs=all": "rcu_nocbs_all",
            "nohz_full=all": "nohz_full_all",
        },
        init=False,
        repr=False,
    )

    PARAM_DESCRIPTIONS: dict[str, str] = field(
        default_factory=lambda: {
            "preempt=full": _("Makes the kernel fully-preemptible, best for lowlatency workloads"),
            "threadirqs": _("Forces interrupt handlers to run in a threaded context, reducing buffer xruns"),
            "rcu_nocbs=all": _("Offloads Read-Copy-Update (RCU) callbacks from all CPUs to dedicated kernel threads, improves real-time performance."),
            "nohz_full=all": _("Reduces noise in latency critical applications such as virtualization"),
        },
        init=False,
        repr=False,
    )

    def as_dict(self) -> dict[str, bool]:
        return {
            param: getattr(self, attr) for param, attr in self.PARAM_MAP.items()
        }

    def as_string_list(self) -> list[str]:
        return [p for p, enabled in self.as_dict().items() if enabled]

    @classmethod
    def from_grub_line(cls, line: str) -> "BootParams":
        obj = cls()
        for param, attr in obj.PARAM_MAP.items():
            if param in line:
                setattr(obj, attr, True)
        return obj


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------


def validate_quantum(buffer_size: int, sample_rate: int) -> bool:
    """Return True if buffer_size and sample_rate are valid."""
    return buffer_size in VALID_BUFFER_SIZES and sample_rate in VALID_SAMPLE_RATES


def _user_conf_path() -> Path:
    """Return the path to the user PipeWire JACK configuration file."""
    return Path.home() / ".config" / "pipewire" / "jack.conf.d" / "ubuntustudio.conf"


def _read_conf_value(key: str) -> str | None:
    """Read a single value from the user PipeWire JACK configuration."""
    conf = _user_conf_path()
    if not conf.exists():
        return None
    for line in conf.read_text().splitlines():
        if key in line and "=" in line:
            return line.split("=", 1)[1].strip()
    return None


# ---------------------------------------------------------------------------
# System queries
# ---------------------------------------------------------------------------


def get_default_quantum() -> tuple[int, int]:
    """Return (buffer, rate) from /etc/profile.d/ubuntustudio-pwjack.sh."""
    sh = Path("/etc/profile.d/ubuntustudio-pwjack.sh")
    if sh.exists():
        for line in sh.read_text().splitlines():
            m = re.search(r"PIPEWIRE_QUANTUM\s*=\s*[\"']?(\d+)/(\d+)", line)
            if m:
                return int(m.group(1)), int(m.group(2))
    return 1024, 48000


def get_user_quantum() -> tuple[int, int]:
    """Return (buffer, rate) from the user config, falling back to defaults."""
    latency = _read_conf_value("node.latency")
    if latency:
        parts = latency.strip('"').strip("'").split("/")
        if len(parts) == 2:
            try:
                return int(parts[0]), int(parts[1])
            except ValueError:
                pass
    return get_default_quantum()


def read_jack_config() -> JackConfig:
    """Parse the full JACK config from disk."""
    cfg = JackConfig()
    buf, rate = get_user_quantum()
    cfg.buffer_size = buf
    cfg.sample_rate = rate

    for param in JACK_BOOL_PARAMS:
        val = _read_conf_value(param)
        if val is not None:
            attr = param.replace("jack.", "").replace("-", "_")
            setattr(cfg, attr, val == "true")

    scm = _read_conf_value("jack.self-connect-mode")
    if scm and scm in SELF_CONNECT_MODES:
        cfg.self_connect_mode = scm

    return cfg


def has_user_conf() -> bool:
    return _user_conf_path().exists()


# ---------------------------------------------------------------------------
# Desktop-extension detection
# ---------------------------------------------------------------------------


def has_plasma_pw_widget() -> bool:
    paths = [
        Path("/usr/share/plasma/plasmoids/com.github.magillos.pipewiresettings"),
        Path.home()
        / ".local/share/plasma/plasmoids/com.github.magillos.pipewiresettings",
    ]
    return any(p.exists() for p in paths)


def has_gnome_pw_extension() -> bool:
    ext = "pipewire-settings@gaheldev.github.com"
    paths = [
        Path(f"/usr/share/gnome-shell/extensions/{ext}"),
        Path.home() / f".local/share/gnome-shell/extensions/{ext}",
    ]
    return any(p.exists() for p in paths)


# ---------------------------------------------------------------------------
# Package / service queries
# ---------------------------------------------------------------------------


def dpkg_installed(pkg: str) -> bool:
    res = subprocess.run(
        ["dpkg", "-s", pkg],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return res.returncode == 0


def is_pipewire_config() -> bool:
    return dpkg_installed("ubuntustudio-pipewire-config")


def pipewire_jack_enabled() -> bool:
    return Path("/etc/ld.so.conf.d/pipewire-jack.conf").exists()


def audio_limits_configured() -> bool:
    return Path("/etc/security/limits.d/30-ubuntustudio-audio.conf").exists()


def user_in_audio_group() -> bool:
    groups = subprocess.run(
        ["groups"], capture_output=True, text=True
    ).stdout
    return "audio" in groups.split()


def firstrun_marker_exists() -> bool:
    return (Path.home() / ".config" / "ubuntustudio-audio-firstrun").exists()


def touch_firstrun_marker() -> None:
    marker = Path.home() / ".config" / "ubuntustudio-audio-firstrun"
    marker.parent.mkdir(parents=True, exist_ok=True)
    marker.touch()


def is_live_session() -> bool:
    import getpass
    return getpass.getuser() == "ubuntu-studio"


# ---------------------------------------------------------------------------
# FireWire / FFADO support
# ---------------------------------------------------------------------------

FFADO_PIPEWIRE_CONF_TEMPLATE = """\
# FireWire (FFADO) audio device configuration for PipeWire
# Generated by Ubuntu Studio Audio Configuration
context.objects = [
    {{ factory = spa-node-factory
       args = {{
           factory.name  = api.ffado.device
           node.name     = "firewire-audio"
           node.description = "FireWire Audio"
           node.latency  = {buffer}/{rate}
           ffado.device  = {device_index}
       }}
    }}
]
"""


@dataclass
class FFADODevice:
    """Basic metadata about a connected FireWire audio device."""

    index: int
    guid: str
    vendor: str
    model: str

    @property
    def display_name(self) -> str:
        return f"{self.vendor} {self.model} (GUID {self.guid})"


def ffado_tools_installed() -> bool:
    """Return True if the ffado-tools package is installed."""
    return dpkg_installed("ffado-tools")


def firewire_controller_present() -> bool:
    """Return True if at least one FireWire (IEEE 1394) controller is present."""
    sysfs = Path("/sys/bus/firewire/devices")
    if sysfs.exists() and any(sysfs.iterdir()):
        return True
    # Fallback: check lspci output
    res = subprocess.run(["lspci"], capture_output=True, text=True)
    return bool(re.search(r"firewire|ieee\s?1394", res.stdout, re.IGNORECASE))


def list_ffado_devices() -> list[FFADODevice]:
    """Return a list of FireWire audio devices visible via sysfs.

    Entries in ``/sys/bus/firewire/devices/`` that expose a
    ``vendor_name`` file are remote nodes (actual devices); entries
    without that file are the local host controller and are skipped.
    """
    devices: list[FFADODevice] = []
    sysfs = Path("/sys/bus/firewire/devices")
    if not sysfs.exists():
        return devices
    idx = 0
    for dev_path in sorted(sysfs.iterdir()):
        vendor_file = dev_path / "vendor_name"
        model_file = dev_path / "model_name"
        if not (vendor_file.exists() and model_file.exists()):
            continue  # local controller — skip
        guid_file = dev_path / "guid"
        guid = guid_file.read_text().strip() if guid_file.exists() else "unknown"
        vendor = vendor_file.read_text().strip()
        model = model_file.read_text().strip()
        devices.append(FFADODevice(index=idx, guid=guid, vendor=vendor, model=model))
        idx += 1
    return devices


def _ffado_conf_path() -> Path:
    """Return the path for the user-level FFADO PipeWire configuration file."""
    return (
        Path.home()
        / ".config"
        / "pipewire"
        / "pipewire.conf.d"
        / "ubuntustudio-ffado.conf"
    )


def ffado_pw_configured() -> bool:
    """Return True if a PipeWire FFADO node config file already exists."""
    return _ffado_conf_path().exists()


def write_ffado_pw_config(
    buffer_size: int,
    sample_rate: int,
    device_index: int = 0,
) -> None:
    """Write the PipeWire FFADO SPA node config and restart PipeWire."""
    path = _ffado_conf_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        FFADO_PIPEWIRE_CONF_TEMPLATE.format(
            buffer=buffer_size,
            rate=sample_rate,
            device_index=device_index,
        )
    )
    subprocess.run(["systemctl", "--user", "restart", "pipewire"])


def remove_ffado_pw_config() -> None:
    """Remove the PipeWire FFADO node config file and restart PipeWire."""
    path = _ffado_conf_path()
    if path.exists():
        path.unlink()
    subprocess.run(["systemctl", "--user", "restart", "pipewire"])


# ---------------------------------------------------------------------------
# Dummy audio device
# ---------------------------------------------------------------------------


def dummy_device_active() -> bool:
    """Return True if the dummy audio service is running."""
    res = subprocess.run(
        ["systemctl", "--user", "status", "ubuntustudio-dummy-audio.service"],
        capture_output=True,
        text=True,
    )
    # If "active" line contains "dead" → not active
    for line in res.stdout.splitlines():
        if "active" in line.lower() and "dead" in line.lower():
            return False
    return res.returncode == 0


def dummy_device_start() -> None:
    subprocess.run(["systemctl", "--user", "start", "ubuntustudio-dummy-audio.service"])


def dummy_device_stop() -> None:
    subprocess.run(["systemctl", "--user", "stop", "ubuntustudio-dummy-audio.service"])


def dummy_device_enable() -> None:
    subprocess.run(
        ["systemctl", "--user", "enable", "ubuntustudio-dummy-audio.service"],
        stderr=subprocess.DEVNULL,
    )


def dummy_device_disable() -> None:
    subprocess.run(
        ["systemctl", "--user", "disable", "ubuntustudio-dummy-audio.service"],
        stderr=subprocess.DEVNULL,
    )


def dummy_device_enabled() -> bool:
    """Return True if dummy audio service is enabled for user login."""
    res = subprocess.run(
        ["systemctl", "--user", "is-enabled", "ubuntustudio-dummy-audio.service"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return res.returncode == 0


# ---------------------------------------------------------------------------
# PipeWire JACK toggle
# ---------------------------------------------------------------------------


def toggle_pipewire_jack() -> bool:
    """Toggle PipeWire-JACK. Returns True on success."""
    if pipewire_jack_enabled():
        cmd = ["pkexec", "ubuntustudio-pwjack-config", "disable"]
    else:
        cmd = ["pkexec", "ubuntustudio-pwjack-config", "enable"]
    return subprocess.run(cmd).returncode == 0


# ---------------------------------------------------------------------------
# Write configuration
# ---------------------------------------------------------------------------

_JACK_CONF_TEMPLATE = """\
#THIS FILE IS AUTOMATICALLY GENERATED BY Ubuntu Studio Audio Configuration
jack.properties = {{
    #rt.prio             = 88
    node.latency        = {buffer}/{rate}
    #node.lock-quantum   = true
    #node.force-quantum  = 0
    jack.show-monitor   = {show_monitor}
    jack.merge-monitor  = {merge_monitor}
    jack.show-midi      = {show_midi}
    jack.short-name     = {short_name}
    #jack.filter-name    = false
    #jack.filter-char    = " "
    #
    # allow:           Don't restrict self connect requests
    # fail-external:   Fail self connect requests to external ports only
    # ignore-external: Ignore self connect requests to external ports only
    # fail-all:        Fail all self connect requests
    # ignore-all:      Ignore all self connect requests
    #jack.self-connect-mode  = {self_connect}
    #jack.locked-process     = true
    #jack.default-as-system  = false
    #jack.fix-midi-events    = true
    jack.global-buffer-size = true
    #jack.passive-links      = false
    #jack.max-client-ports   = 768
    #jack.fill-aliases       = false
    #jack.writable-input     = false
    #jack.flag-midi2         = false
}}
"""

_JACK_CONF_TEMPLATE_NO_QUANTUM = """\
#THIS FILE IS AUTOMATICALLY GENERATED BY Ubuntu Studio Audio Configuration
#Buffer and sample rate are managed by the desktop widget / extension.
jack.properties = {{
    #rt.prio             = 88
    #node.lock-quantum   = true
    #node.force-quantum  = 0
    jack.show-monitor   = {show_monitor}
    jack.merge-monitor  = {merge_monitor}
    jack.show-midi      = {show_midi}
    jack.short-name     = {short_name}
    #jack.filter-name    = false
    #jack.filter-char    = " "
    #
    # allow:           Don't restrict self connect requests
    # fail-external:   Fail self connect requests to external ports only
    # ignore-external: Ignore self connect requests to external ports only
    # fail-all:        Fail all self connect requests
    # ignore-all:      Ignore all self connect requests
    #jack.self-connect-mode  = {self_connect}
    #jack.locked-process     = true
    #jack.default-as-system  = false
    #jack.fix-midi-events    = true
    jack.global-buffer-size = true
    #jack.passive-links      = false
    #jack.max-client-ports   = 768
    #jack.fill-aliases       = false
    #jack.writable-input     = false
    #jack.flag-midi2         = false
}}
"""


def write_jack_config(cfg: JackConfig, *, set_quantum: bool = True) -> None:
    """Write the JACK configuration and restart PipeWire.

    If *set_quantum* is False the config file is written **without**
    ``node.latency`` and the ``pw-metadata`` calls are skipped — the
    desktop widget / extension is responsible for quantum persistence.
    """
    conf_path = _user_conf_path()
    conf_path.parent.mkdir(parents=True, exist_ok=True)

    fmt_args = dict(
        show_monitor=str(cfg.show_monitor).lower(),
        merge_monitor=str(cfg.merge_monitor).lower(),
        show_midi=str(cfg.show_midi).lower(),
        short_name=str(cfg.short_name).lower(),
        self_connect=cfg.self_connect_mode,
    )

    if set_quantum:
        fmt_args["buffer"] = cfg.buffer_size
        fmt_args["rate"] = cfg.sample_rate
        text = _JACK_CONF_TEMPLATE.format(**fmt_args)
    else:
        text = _JACK_CONF_TEMPLATE_NO_QUANTUM.format(**fmt_args)

    conf_path.write_text(text)

    subprocess.run(["systemctl", "--user", "restart", "pipewire"])
    if set_quantum:
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-quantum", str(cfg.buffer_size)]
        )
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-rate", str(cfg.sample_rate)]
        )


# ---------------------------------------------------------------------------
# Boot parameters / GRUB
# ---------------------------------------------------------------------------


def read_boot_params() -> BootParams:
    """Parse current boot parameters from ubuntustudio.cfg."""
    cfg_path = Path("/etc/default/grub.d/ubuntustudio.cfg")
    if cfg_path.exists():
        for line in cfg_path.read_text().splitlines():
            if "GRUB_CMDLINE_LINUX_DEFAULT" in line:
                return BootParams.from_grub_line(line)
    return BootParams()


def write_boot_params(params: BootParams) -> bool:
    """Write boot parameters via pkexec. Returns True on success."""
    param_str = " ".join(params.as_string_list())
    grub_cfg = (
        '# Activate lowlatency effects of kernel\n'
        f'GRUB_CMDLINE_LINUX_DEFAULT="$GRUB_CMDLINE_LINUX_DEFAULT {param_str}"\n'
        '# Install GRUB with Ubuntu Studio as the distributor\n'
        'if dpkg-query -W ubuntustudio-desktop-core >/dev/null 2>&1; then\n'
        '    GRUB_DISTRIBUTOR="UbuntuStudio"\n'
        'fi'
    )
    cmd = [
        "pkexec",
        "/usr/bin/ubuntustudio-audio-config",
        "writeparams",
        grub_cfg,
    ] + params.as_string_list()
    return subprocess.run(cmd).returncode == 0


# ---------------------------------------------------------------------------
# CLI-only operations (no GUI needed)
# ---------------------------------------------------------------------------


def cli_startup() -> None:
    """Run the 'startup' logic — set PipeWire quantum from config on login."""
    conf = _user_conf_path()
    if not conf.exists():
        buf, rate = get_default_quantum()
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-quantum", str(buf)]
        )
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-rate", str(rate)]
        )
        cfg = JackConfig(buffer_size=buf, sample_rate=rate)
        write_jack_config(cfg)
    else:
        buf, rate = get_user_quantum()
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-quantum", str(buf)]
        )
        subprocess.run(
            ["pw-metadata", "-n", "settings", "0", "clock.force-rate", str(rate)]
        )


def cli_dummy_start() -> None:
    subprocess.run([
        "pactl", "load-module", "module-null-sink",
        "media.class=Audio/Sink", "sink_name=Dummy", "channel_map=stereo",
    ])
    subprocess.run([
        "pactl", "load-module", "module-virtual-source",
        "media.class=Audio/Source", "source_name=Dummy", "channel_map=stereo",
    ])


def cli_dummy_stop() -> None:
    subprocess.run(["pactl", "unload-module", "module-null-sink"])
    subprocess.run(["pactl", "unload-module", "module-virtual-source"])


def cli_write_params(grub_cfg_text: str, extra_params: list[str]) -> None:
    """Write GRUB config and update-grub (runs as root via pkexec)."""
    Path("/etc/default/grub.d/ubuntustudio.cfg").write_text(grub_cfg_text + "\n")
    subprocess.run(["update-grub"])

    refind = Path("/boot/refind_linux.conf")
    if refind.exists():
        text = refind.read_text()
        if "lowlatency" in text.lower():
            root_uuid = subprocess.run(
                ["lsblk", "-no", "UUID", subprocess.run(
                    ["df", "-P", "/"], capture_output=True, text=True
                ).stdout.strip().splitlines()[-1].split()[0]],
                capture_output=True,
                text=True,
            ).stdout.strip()
            param_str = " ".join(extra_params)
            new_line = (
                f'"Boot with lowlatency options" '
                f'"root=UUID={root_uuid} ro quiet splash {param_str} vt.handoff=1"'
            )
            lines = text.splitlines()
            for i, line in enumerate(lines):
                if "lowlatency" in line.lower():
                    lines[i] = new_line
            refind.write_text("\n".join(lines) + "\n")


def run_installer_fix() -> bool:
    """Run the installer fix via pkexec. Returns True on success."""
    return subprocess.run(["pkexec", "/usr/sbin/ubuntustudio-installer-helper", "fix"]).returncode == 0


def run_installer_fix_and_reboot() -> bool:
    """Run the first-run audio fix and reboot via a dedicated helper.

    Uses a separate pkexec helper with a no-auth polkit policy so the
    user is not prompted for a password (they already confirmed via the
    GUI 'Reboot' button).
    """
    return subprocess.run(["pkexec", "/usr/sbin/ubuntustudio-audio-firstrun"]).returncode == 0


def install_pipewire_config() -> None:
    subprocess.run(["qapt-batch", "--install", "ubuntustudio-pipewire-config"])
