import pickle
import os
import socket
import subprocess
import time
import traceback
import pwd
import grp
from typing import List, Dict
from datetime import datetime
import sam.quick
from sam.actions import Action, Task
from sam.managers.utils import run_action, get_managers_dict
import sam.constants as constants

import dbus, dbus.service, dbus.mainloop.glib
import threading


class AuthorizationError(dbus.DBusException):
    _dbus_error_name = 'io.stillhq.SamService.AuthorizationError'

# Auto-update timing constants (in seconds)
INITIAL_UPDATE_DELAY = 15 * 60  # 15 minutes after boot
UPDATE_INTERVAL = 24 * 60 * 60  # 24 hours
RETRY_INTERVAL = 30
NETWORK_RETRY_INTERVAL = 20

try:
    import sadb.database as db
    sadb = True
except ImportError:
    sadb = False


def sadb_update_installed():
    if sadb:
        try:
            db.update_installed()
        except Exception as e:
            pass # We don't care if this errors


def get_app_name(package_name, package_source, manager) -> str:
    try:
        app_info = manager.bare_app_info(package_name)
        if app_info is not None and app_info.get("name"):
            return str(app_info["name"])
    except Exception as e:
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(
            f"[{timestamp}] Manager metadata lookup failed for {package_name}, {package_source}: {e}",
            flush=True,
        )

    return package_name


def classify_failure(error: str) -> str:
    error_text = (error or "").lower()
    network_terms = (
        "network",
        "connection",
        "could not resolve",
        "resolve host",
        "name or service not known",
        "temporary failure",
        "timed out",
        "timeout",
        "connection reset",
        "connection refused",
        "couldn't connect",
        "tls",
        "ssl",
        "internet",
        "offline",
    )
    storage_terms = (
        "no space left",
        "disk full",
        "read-only file system",
        "permission denied",
        "operation not permitted",
        "cannot open",
        "failed to open",
        "lock",
        "locked",
        "busy",
    )

    if any(term in error_text for term in network_terms):
        return "network"
    if any(term in error_text for term in storage_terms):
        return "storage"
    return "unknown"


def is_retryable_failure(kind: str) -> bool:
    return kind in {"network", "storage"}


def is_network_available() -> bool:
    try:
        nm_proxy = dbus.SystemBus().get_object(
            "org.freedesktop.NetworkManager",
            "/org/freedesktop/NetworkManager",
        )
        nm_iface = dbus.Interface(nm_proxy, "org.freedesktop.NetworkManager")
        return int(nm_iface.state()) == 70
    except Exception:
        pass

    for host, port in (("1.1.1.1", 53), ("8.8.8.8", 53)):
        try:
            with socket.create_connection((host, port), timeout=3):
                return True
        except OSError:
            continue
    return False


class SamService(dbus.service.Object):
    ALLOWED_SYSTEM_ACTIONS = {
        ("system-update", Task.UPDATE): {"system"},
        ("system", Task.UPDATE): {"system"},
        ("nvidia-driver", Task.INSTALL): {"system", "nvidia"},
    }
    # Per-manager queues: manager_id -> list of actions
    queues: Dict[str, List[Action]] = {}
    # Track running queue manager threads per manager
    manager_threads: Dict[str, threading.Thread] = {}
    # Lock for thread-safe queue operations
    queue_lock: threading.Lock

    available_updates: List[Action] = []
    update_check_lock: threading.Lock
    sadb_update_lock: threading.Lock
    bus: dbus.SystemBus

    def __init__(self):
        self.bus = dbus.SystemBus()
        bus_name = dbus.service.BusName('io.stillhq.SamService', bus=self.bus)
        dbus.service.Object.__init__(self, bus_name, '/io/stillhq/SamService')
        self.queues = {}
        self.manager_threads = {}
        self.queue_lock = threading.Lock()
        self.update_check_lock = threading.Lock()
        self.sadb_update_lock = threading.Lock()
        self.notified_failures = {}
        self._load_saved_queues()
        
        # Start auto-update thread
        auto_update_thread = threading.Thread(target=self._auto_update_loop, daemon=True)
        auto_update_thread.start()
        self._start_queued_managers()

    def _get_sender_uid(self, sender: str) -> int:
        dbus_proxy = self.bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus')
        dbus_iface = dbus.Interface(dbus_proxy, 'org.freedesktop.DBus')
        return int(dbus_iface.GetConnectionUnixUser(sender))

    def _get_access_details_for_uid(self, uid: int) -> dict:
        user = pwd.getpwuid(uid)
        group_ids = set(os.getgrouplist(user.pw_name, user.pw_gid))
        group_names = {grp.getgrgid(group_id).gr_name for group_id in group_ids}
        is_root = uid == 0
        in_wheel = "wheel" in group_names
        in_sam_group = "sam" in group_names
        allowed = is_root or in_wheel or in_sam_group

        return {
            "allowed": allowed,
            "uid": uid,
            "username": user.pw_name,
            "is_root": is_root,
            "in_wheel": in_wheel,
            "in_sam_group": in_sam_group,
        }

    def _get_access_details_for_sender(self, sender: str) -> dict:
        return self._get_access_details_for_uid(self._get_sender_uid(sender))

    def _require_access(self, sender: str):
        access = self._get_access_details_for_sender(sender)
        if not access["allowed"]:
            raise AuthorizationError(
                f"User '{access['username']}' is not allowed to use privileged SAM methods"
            )

    def progress_trigger(self, progress: int):
        self.write_queue()
        self.progress_changed(progress)

    @dbus.service.signal('io.stillhq.SamService')
    def queue_changed(self):
        pass

    @dbus.service.signal('io.stillhq.SamService', signature="i")
    def progress_changed(self, progress: int):
        pass

    @dbus.service.signal('io.stillhq.SamService', signature="a{sv}")
    def error_occurred(self, action):
        pass

    @dbus.service.signal('io.stillhq.SamService', signature="a{sv}")
    def action_completed(self, action):
        pass

    def _get_manager_queue(self, manager_id: str) -> List[Action]:
        """Get or create the queue for a specific manager."""
        if manager_id not in self.queues:
            self.queues[manager_id] = []
        return self.queues[manager_id]

    def _load_saved_queues(self):
        """Load persisted queues from disk and resume unfinished actions."""
        saved_queues = sam.quick.get_manager_queue_dicts()
        loaded_count = 0
        for manager_id, actions in saved_queues.items():
            restored_actions = []
            for action_dict in actions:
                try:
                    action = Action.from_dict(action_dict)
                    action.running = False
                    action.progress_trigger = None
                    restored_actions.append(action)
                except Exception as error:
                    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    print(f"[{timestamp}] Failed to restore queued action for {manager_id}: {error}", flush=True)
            if restored_actions:
                self.queues[manager_id] = restored_actions
                loaded_count += len(restored_actions)

        if loaded_count:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] Restored {loaded_count} queued SAM action(s)", flush=True)
            self.write_queue()

    def _start_queued_managers(self):
        """Start queue workers for managers that had persisted actions."""
        with self.queue_lock:
            manager_ids = [manager_id for manager_id, queue in self.queues.items() if queue]
        for manager_id in manager_ids:
            self._ensure_manager_thread(manager_id)

    def _emit_action_failure(self, action: Action, kind: str):
        action.failure_kind = kind
        action.can_export_logs = not is_retryable_failure(kind)
        failure_key = (action.manager_id, action.package_id, action.task.to_str(), action.error)
        if self.notified_failures.get(failure_key):
            return
        self.notified_failures[failure_key] = True
        self.error_occurred(action.to_dict())

    def _wait_for_retry_condition(self, kind: str):
        if kind == "network":
            while not is_network_available():
                time.sleep(NETWORK_RETRY_INTERVAL)
            return
        time.sleep(RETRY_INTERVAL)

    def _ensure_manager_thread(self, manager_id: str):
        """Ensure a queue manager thread is running for this manager."""
        # Check if thread exists and is alive
        if manager_id in self.manager_threads:
            if self.manager_threads[manager_id].is_alive():
                return  # Thread already running

        # Start a new thread for this manager
        thread = threading.Thread(
            target=self._queue_manager_for_manager,
            args=(manager_id,),
            daemon=True
        )
        self.manager_threads[manager_id] = thread
        thread.start()
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Started queue manager for {manager_id}", flush=True)

    def add_to_queue(self, action: Action):
        """Add an action to the appropriate manager's queue."""
        with self.queue_lock:
            manager_queue = self._get_manager_queue(action.manager_id)
            # Check if action for this package is already in the queue
            for existing_action in manager_queue:
                if existing_action.package_id == action.package_id:
                    return
            manager_queue.append(action)
            self._ensure_manager_thread(action.manager_id)
        self.write_queue()
        self.queue_changed()

    @dbus.service.method('io.stillhq.SamService', in_signature='a{sv}', sender_keyword='sender')
    def add_dict_to_queue(self, action_dict: dict, sender=None):
        self._require_access(sender)
        action = Action.from_dict(action_dict)
        if action.manager_id == "system":
            required_tags = self.ALLOWED_SYSTEM_ACTIONS.get((action.package_id, action.task))
            if required_tags is None:
                raise AuthorizationError("This system transaction is not allowlisted by SAM")
            action.tags = sorted(required_tags)
        with self.queue_lock:
            manager_queue = self._get_manager_queue(action.manager_id)
            for queue_action in manager_queue:
                if queue_action.package_id == action.package_id:
                    if action.task == Task.REMOVE and not queue_action.running:
                        manager_queue.remove(queue_action)
                    else:
                        return
            manager_queue.append(action)
            self._ensure_manager_thread(action.manager_id)
        self.write_queue()
        self.queue_changed()

    def _check_and_queue_updates(self, background: bool) -> int:
        """Check for updates across managers and queue them.

        Returns the number of newly queued updates. If another update check is
        already running, returns 0 immediately.
        """
        if not self.update_check_lock.acquire(blocking=False):
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] Update check already running, skipping duplicate request", flush=True)
            return 0

        try:
            initial_queue_size = sum(len(queue) for queue in self.queues.values())
            managers = get_managers_dict()
            for manager in managers:
                try:
                    for package in managers[manager].check_updates():
                        action = Action()
                        action.package_id = str(package)
                        action.app_name = get_app_name(package, manager, managers[manager])
                        action.manager_id = manager
                        action.task = Task.UPDATE
                        action.background = background
                        if manager == "system":
                            action.tags = ["system"]
                        self.add_to_queue(action)
                except Exception as e:
                    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    print(f"[{timestamp}] Error checking updates for {manager}: {e}", flush=True)
                    traceback.print_exc()

            final_queue_size = sum(len(queue) for queue in self.queues.values())
            return final_queue_size - initial_queue_size
        finally:
            self.update_check_lock.release()

    @dbus.service.method('io.stillhq.SamService', in_signature='b', sender_keyword='sender')
    def update_all(self, background, sender=None):
        self._require_access(sender)
        threading.Thread(
            target=self._check_and_queue_updates,
            args=(background,),
            daemon=True,
        ).start()

    @dbus.service.method('io.stillhq.SamService', in_signature='b', sender_keyword='sender')
    def update_all_blocking(self, background, sender=None):
        self._require_access(sender)
        self._check_and_queue_updates(background)

    def _refresh_sadb(self):
        if not sadb:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB refresh requested but sadb is not installed", flush=True)
            return

        if not self.sadb_update_lock.acquire(blocking=False):
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB refresh already running, skipping duplicate request", flush=True)
            return

        try:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB: refreshing database", flush=True)
            db.update_db()
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB: refreshing installed apps", flush=True)
            db.update_installed()
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB: refresh complete", flush=True)
        except Exception as e:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] SADB refresh failed: {e}", flush=True)
            traceback.print_exc()
        finally:
            self.sadb_update_lock.release()

    @dbus.service.method('io.stillhq.SamService', sender_keyword='sender')
    def update_sadb(self, sender=None):
        self._require_access(sender)
        threading.Thread(
            target=self._refresh_sadb,
            daemon=True,
        ).start()

    @dbus.service.method('io.stillhq.SamService', sender_keyword='sender')
    def update_sadb_blocking(self, sender=None):
        self._require_access(sender)
        self._refresh_sadb()

    @dbus.service.method('io.stillhq.SamService', sender_keyword='sender')
    def remove_from_queue(self, package_name: str, sender=None):
        self._require_access(sender)
        with self.queue_lock:
            for manager_id, queue in self.queues.items():
                for action in queue:
                    if action.package_id == package_name:
                        queue.remove(action)
                        break
        self.write_queue()
        self.queue_changed()

    @dbus.service.method('io.stillhq.SamService')
    def get_queue_actions_dict(self) -> List[dict]:
        """Get all actions from all manager queues."""
        with self.queue_lock:
            all_actions = []
            for manager_id, queue in self.queues.items():
                all_actions.extend([action.to_dict() for action in queue])
            if len(all_actions) == 0:
                return None
            return all_actions

    @dbus.service.method('io.stillhq.SamService')
    def get_version(self):
        return constants.VERSION

    @dbus.service.method('io.stillhq.SamService', out_signature='b', sender_keyword='sender')
    def can_access(self, sender=None):
        return self._get_access_details_for_sender(sender)["allowed"]

    @dbus.service.method('io.stillhq.SamService', out_signature='a{sv}', sender_keyword='sender')
    def get_access_info(self, sender=None):
        access = self._get_access_details_for_sender(sender)
        return {
            "allowed": dbus.Boolean(access["allowed"]),
            "uid": dbus.UInt32(access["uid"]),
            "username": access["username"],
            "is_root": dbus.Boolean(access["is_root"]),
            "in_wheel": dbus.Boolean(access["in_wheel"]),
            "in_sam_group": dbus.Boolean(access["in_sam_group"]),
        }

    @dbus.service.method('io.stillhq.SamService', out_signature='s', sender_keyword='sender')
    def export_logs(self, sender=None):
        uid = self._get_sender_uid(sender) if sender is not None else 0
        user = pwd.getpwuid(uid)
        export_dir = os.path.join(user.pw_dir, "Downloads")
        if not os.path.isdir(export_dir):
            export_dir = os.path.join(user.pw_dir, ".cache", "sam")
        os.makedirs(export_dir, exist_ok=True)

        timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        export_path = os.path.join(export_dir, f"sam-logs-{timestamp}.txt")

        try:
            journal = subprocess.run(
                ["journalctl", "-u", "sam.service", "-b", "--no-pager", "-n", "700"],
                check=False,
                capture_output=True,
                text=True,
            )
            journal_output = journal.stdout or journal.stderr or "No journal output available."
        except Exception as error:
            journal_output = f"Unable to collect journal logs: {error}"

        with self.queue_lock:
            queue_snapshot = {
                manager_id: [action.to_dict() for action in queue]
                for manager_id, queue in self.queues.items()
            }

        with open(export_path, "w", encoding="utf-8") as log_file:
            log_file.write(f"SAM log export: {datetime.now().isoformat()}\n")
            log_file.write(f"SAM version: {constants.VERSION}\n\n")
            log_file.write("Queued actions:\n")
            for manager_id, actions in queue_snapshot.items():
                log_file.write(f"- {manager_id}: {actions}\n")
            log_file.write("\nJournal:\n")
            log_file.write(journal_output)

        os.chown(export_path, uid, user.pw_gid)
        return export_path

    def write_queue(self):
        """Write each manager's queue to its own file."""
        with self.queue_lock:
            queue_dir = os.path.dirname(sam.quick.QUEUE_LOCATION) or "/var/lib/sam"
            os.makedirs(queue_dir, exist_ok=True)
            for manager_id, queue in self.queues.items():
                queue_file = sam.quick.get_queue_file_for_manager(manager_id)
                actions = [action.to_dict() for action in queue] if queue else None
                with open(queue_file, "wb") as file:
                    pickle.dump(actions, file)

    def _queue_manager_for_manager(self, manager_id: str):
        """Queue manager thread for a specific manager."""
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Queue manager running for {manager_id}", flush=True)
        while True:
            action = None
            with self.queue_lock:
                manager_queue = self._get_manager_queue(manager_id)
                if len(manager_queue) > 0:
                    action = manager_queue[0]

            if action is None:
                # No more actions for this manager, thread can exit
                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                print(f"[{timestamp}] Queue manager for {manager_id} finished (queue empty)", flush=True)
                break

            action.progress_trigger = self.progress_trigger
            run_action(action)

            if action.error and action.error != "":
                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                print(f"[{timestamp}] Error: {action.error}", flush=True)
                failure_kind = classify_failure(action.error)
                self._emit_action_failure(action, failure_kind)
                action.running = False
                action.status = f"Waiting to retry after {failure_kind} failure"
                self.write_queue()
                self.queue_changed()
                if is_retryable_failure(failure_kind):
                    self._wait_for_retry_condition(failure_kind)
                    continue

            sadb_update_installed()

            if not action.error:
                self.action_completed(action.to_dict())

            with self.queue_lock:
                manager_queue = self._get_manager_queue(manager_id)
                if len(manager_queue) > 0 and manager_queue[0] == action:
                    manager_queue.pop(0)

            self.write_queue()
            self.queue_changed()

    def _auto_update_loop(self):
        """Background thread that automatically checks for updates."""
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Auto-update: waiting {INITIAL_UPDATE_DELAY // 60} minutes for first update check...", flush=True)
        time.sleep(INITIAL_UPDATE_DELAY)
        
        while True:
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] Auto-update: checking for updates...", flush=True)
            try:
                updates_found = self._check_and_queue_updates(True)
                
                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                print(f"[{timestamp}] Auto-update: update check complete. Found {updates_found} update(s)", flush=True)
            except Exception as e:
                timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                print(f"[{timestamp}] Auto-update: error checking for updates: {e}", flush=True)
            
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] Auto-update: next check in {UPDATE_INTERVAL // 3600} hours", flush=True)
            time.sleep(UPDATE_INTERVAL)
