"""SABnzbd adapter.

Auth model: a single API key passed as ``?apikey=...`` on every
request. No login flow. Every endpoint is the same path ``/api`` with
a ``mode=`` query param.

Reference: https://sabnzbd.org/wiki/configuration/4.3/api
"""

from __future__ import annotations

import asyncio
from typing import List, Optional, Union

import requests as http_requests

from config.settings import config_manager
from core.usenet_clients.base import UsenetStatus
from utils.logging_config import get_logger

logger = get_logger("usenet.sabnzbd")


# SAB Status enum (sabnzbd/constants.py:~95-118) → adapter-uniform set.
# SAB emits PascalCase strings (``Idle``, ``Downloading``, ...) under
# the slot ``status`` field; this map is keyed lowercase because
# ``_map_state`` normalises before lookup. Anything unmapped lands on
# ``error`` via ``_map_state``'s default — the album-bundle poll
# helper treats that default as a transient miss so a brand-new
# unmapped state can't infinite-loop the poll.
_SAB_QUEUE_STATE_MAP = {
    "idle":         "queued",
    "queued":       "queued",
    "grabbing":     "queued",
    "propagating":  "queued",
    "fetching":     "downloading",
    "downloading":  "downloading",
    "paused":       "paused",
    "checking":     "verifying",
    "quickcheck":   "verifying",
    "verifying":    "verifying",
    "repairing":    "repairing",
    "extracting":   "extracting",
    "moving":       "extracting",
    "running":      "extracting",
    "completed":    "completed",
    "failed":       "failed",
    "deleted":      "failed",
}


def _map_state(sab_state: str) -> str:
    return _SAB_QUEUE_STATE_MAP.get((sab_state or "").lower(), "error")


class SABnzbdAdapter:
    """SABnzbd REST API adapter (v2+)."""

    DEFAULT_TIMEOUT = 15

    def __init__(self) -> None:
        self._load_config()

    def _load_config(self) -> None:
        self._url = (config_manager.get('usenet_client.url', '') or '').rstrip('/')
        self._api_key = config_manager.get('usenet_client.api_key', '') or ''
        self._category = config_manager.get('usenet_client.category', 'soulsync') or 'soulsync'

    def reload_settings(self) -> None:
        self._load_config()

    def is_configured(self) -> bool:
        return bool(self._url and self._api_key)

    async def check_connection(self) -> bool:
        if not self.is_configured():
            return False
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._check_connection_sync)

    def _check_connection_sync(self) -> bool:
        # ``mode=version`` is the cheapest authenticated probe SAB exposes.
        data = self._call_sync('version')
        return bool(data and data.get('version'))

    def _call_sync(self, mode: str, **extra) -> Optional[dict]:
        if not self.is_configured():
            return None
        params = {
            'mode': mode,
            'output': 'json',
            'apikey': self._api_key,
        }
        params.update(extra)
        try:
            resp = http_requests.get(f"{self._url}/api", params=params, timeout=self.DEFAULT_TIMEOUT)
            if not resp.ok:
                logger.warning("SABnzbd mode=%s returned HTTP %s", mode, resp.status_code)
                return None
            return resp.json()
        except http_requests.exceptions.RequestException as e:
            logger.error("SABnzbd mode=%s request failed: %s", mode, e)
            return None
        except ValueError as e:
            logger.error("SABnzbd mode=%s response was not JSON: %s", mode, e)
            return None

    def _post_sync(self, mode: str, files=None, **extra) -> Optional[dict]:
        if not self.is_configured():
            return None
        params = {
            'mode': mode,
            'output': 'json',
            'apikey': self._api_key,
        }
        params.update(extra)
        try:
            resp = http_requests.post(f"{self._url}/api", params=params, files=files,
                                      timeout=self.DEFAULT_TIMEOUT)
            if not resp.ok:
                logger.warning("SABnzbd POST mode=%s returned HTTP %s", mode, resp.status_code)
                return None
            return resp.json()
        except http_requests.exceptions.RequestException as e:
            logger.error("SABnzbd POST mode=%s failed: %s", mode, e)
            return None
        except ValueError as e:
            logger.error("SABnzbd POST mode=%s response was not JSON: %s", mode, e)
            return None

    async def add_nzb(
        self,
        url_or_bytes: Union[str, bytes],
        category: str = "soulsync",
        save_path: Optional[str] = None,
    ) -> Optional[str]:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(
            None, self._add_nzb_sync, url_or_bytes, category, save_path
        )

    def _add_nzb_sync(
        self,
        url_or_bytes: Union[str, bytes],
        category: str,
        save_path: Optional[str],
    ) -> Optional[str]:
        cat = category or self._category
        if isinstance(url_or_bytes, bytes):
            files = {'name': ('soulsync.nzb', url_or_bytes, 'application/x-nzb')}
            data = self._post_sync('addfile', files=files, cat=cat)
        else:
            data = self._call_sync('addurl', name=url_or_bytes, cat=cat)
        if not data or not data.get('status'):
            return None
        ids = data.get('nzo_ids') or []
        return ids[0] if ids else None

    async def get_status(self, job_id: str) -> Optional[UsenetStatus]:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._get_status_sync, job_id)

    def _get_status_sync(self, job_id: str) -> Optional[UsenetStatus]:
        # Direct nzo_ids lookup against queue, then history. Falls back
        # to the bulk fetch for SAB versions that don't honour the
        # nzo_ids filter (very old SAB), but the direct path is the hot
        # path because the bulk history fetch was limited to 50 entries
        # — on a busy SAB server a recently-completed job would roll
        # past the window and the poll would log "disappeared".
        if not job_id:
            return None
        queue = self._call_sync('queue', nzo_ids=job_id)
        if queue and isinstance(queue.get('queue'), dict):
            for slot in queue['queue'].get('slots', []) or []:
                if str(slot.get('nzo_id') or '') == job_id:
                    return self._parse_queue_slot(slot)
        history = self._call_sync('history', nzo_ids=job_id)
        if history and isinstance(history.get('history'), dict):
            for slot in history['history'].get('slots', []) or []:
                if str(slot.get('nzo_id') or '') == job_id:
                    return self._parse_history_slot(slot)
        # Fallback: SAB version pre-dating nzo_ids filter support. The
        # bulk path is still limit=50; the helper's transient-miss
        # tolerance will cover the gap if the entry briefly rolls out
        # of the window.
        for status in self._get_all_sync():
            if status.id == job_id:
                return status
        return None

    async def get_all(self) -> List[UsenetStatus]:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._get_all_sync)

    def _get_all_sync(self) -> List[UsenetStatus]:
        out: List[UsenetStatus] = []
        # Active queue
        queue = self._call_sync('queue')
        if queue and isinstance(queue.get('queue'), dict):
            for slot in queue['queue'].get('slots', []) or []:
                out.append(self._parse_queue_slot(slot))
        # History — completed / failed jobs SAB still tracks
        history = self._call_sync('history', limit=50)
        if history and isinstance(history.get('history'), dict):
            for slot in history['history'].get('slots', []) or []:
                out.append(self._parse_history_slot(slot))
        return out

    def _parse_queue_slot(self, slot: dict) -> UsenetStatus:
        try:
            percentage = float(slot.get('percentage') or 0.0)
        except (TypeError, ValueError):
            percentage = 0.0
        progress = percentage / 100.0
        # mb / mbleft are strings of MB values in SAB's queue API.
        size_mb = self._safe_float(slot.get('mb'))
        left_mb = self._safe_float(slot.get('mbleft'))
        size_bytes = int(size_mb * 1024 * 1024) if size_mb else 0
        downloaded_bytes = int((size_mb - left_mb) * 1024 * 1024) if size_mb and left_mb is not None else 0
        # ``timeleft`` is HH:MM:SS — convert to seconds.
        eta = self._parse_timeleft(slot.get('timeleft'))
        return UsenetStatus(
            id=str(slot.get('nzo_id') or ''),
            name=slot.get('filename') or slot.get('name') or '',
            state=_map_state(slot.get('status') or ''),
            progress=max(0.0, min(progress, 1.0)),
            size=size_bytes,
            downloaded=max(0, downloaded_bytes),
            download_speed=0,  # queue endpoint doesn't include per-slot speed
            eta=eta,
            category=slot.get('cat'),
        )

    def _parse_history_slot(self, slot: dict) -> UsenetStatus:
        # History entries cover BOTH finished jobs AND jobs that are still
        # POST-PROCESSING. SAB removes a job from the queue the moment the
        # download finishes and runs par2 verify / repair / unpack / move
        # while the job sits in History — exposing the live stage in the
        # ``status`` field ('Verifying' / 'Repairing' / 'Extracting' /
        # 'Moving' / 'Running' / ...). Only 'Completed' means truly done;
        # 'Failed' / 'Deleted' mean failure.
        #
        # The old logic mapped EVERY non-'failed' status to 'completed'.
        # That made the poll treat a still-extracting 1.7 GB FLAC album
        # (status 'Extracting', ``storage`` not written yet) as "completed
        # but no save_path" and burn the completed-no-path window mid-PP —
        # exactly the #721 stuck-at-99% signature in production where the
        # path IS shared. Route the status through the same queue-state map
        # so PP stages stay NON-terminal: the poll keeps waiting (as
        # 'downloading') for as long as post-processing takes, and only a
        # real 'Completed' flips it to terminal success.
        mapped = _map_state(slot.get('status') or '')
        is_failed = mapped == 'failed'
        is_completed = mapped == 'completed'
        # Only trust the final path on a TRUE completion. Mid-PP the path
        # fields may be empty or still point at the incomplete dir; the
        # completed-no-path retry handles the brief gap between the
        # 'Completed' flip and ``storage`` landing in the same response.
        save_path = self._extract_history_save_path(slot) if is_completed else None
        # ``incomplete_path`` is surfaced separately (NOT as save_path) so
        # the poll loops can fall back to it only as a last resort once
        # the final ``storage`` field has provably failed to land — see
        # ``UsenetStatus.incomplete_path`` and the #721 fallback in
        # ``poll_album_download``. Whitespace-only values are treated as
        # absent, same as the save_path chain.
        incomplete_path = slot.get('incomplete_path')
        if not (isinstance(incomplete_path, str) and incomplete_path.strip()):
            incomplete_path = None
        bytes_total = int(slot.get('bytes') or 0)
        return UsenetStatus(
            id=str(slot.get('nzo_id') or ''),
            name=slot.get('name') or '',
            state=mapped,
            # Download itself is finished for ANY History slot (in-PP or
            # done), so report full download progress — don't snap the UI
            # back to 0% while SAB verifies/unpacks. Failed = 0.
            progress=0.0 if is_failed else 1.0,
            size=bytes_total,
            downloaded=0 if is_failed else bytes_total,
            download_speed=0,
            save_path=save_path,
            incomplete_path=incomplete_path,
            category=slot.get('category'),
            error=slot.get('fail_message') if is_failed else None,
        )

    # SAB version differences: ``storage`` is the documented final-path
    # field on recent versions, but older builds populated ``path``
    # instead, and some forks use ``download_path`` or ``dirname``.
    # ``storage`` is also empty for the first few seconds after a job
    # flips to History — SAB writes the path AFTER its post-processing
    # move completes. Issue #721 (Forty Licks stuck at 61%): the bundle
    # poll returned save_path=None on the first ``Completed`` read, the
    # plugin marked the batch failed, and the UI never unstuck. The
    # ``poll_album_download`` retry loop now tolerates a short window
    # of completed-but-no-path; this helper widens the field net so
    # we pick the path up whenever ANY of the known SAB keys carries it.
    _HISTORY_SAVE_PATH_KEYS = (
        'storage',
        'path',
        'download_path',
        'dirname',
    )
    # ``incomplete_path`` is intentionally NOT in this list. SAB
    # populates it BEFORE the post-process move, so it would always
    # resolve on the first ``Completed`` read — bypassing
    # ``poll_album_download``'s new retry window, and pointing the
    # bundle plugin at the in-progress staging dir instead of the
    # final destination. The poll's retry loop is the safer place to
    # handle the SAB History→storage write gap.

    def _extract_history_save_path(self, slot: dict) -> Optional[str]:
        for key in self._HISTORY_SAVE_PATH_KEYS:
            value = slot.get(key)
            if value and isinstance(value, str) and value.strip():
                return value
        # Loud diagnostic when the bundle poll is about to wait/fail on
        # this: users on SAB versions / forks with novel field names need
        # to see this in the logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``.
        # INFO (not DEBUG) on purpose — a completed History slot with no
        # resolvable path is the #721 stuck-bundle signature, and dumping
        # the actual slot keys here is what lets us add the missing field
        # without a debug-logging round-trip. It only fires for completed
        # slots that lack every known path field, so it self-limits the
        # moment ``storage`` lands.
        logger.info(
            "[SAB] History slot for nzo_id=%s has no save_path in any "
            "of the known fields %r — slot keys: %r",
            slot.get('nzo_id'),
            self._HISTORY_SAVE_PATH_KEYS,
            sorted(slot.keys()),
        )
        return None

    @staticmethod
    def _safe_float(value) -> Optional[float]:
        if value is None or value == '':
            return None
        try:
            return float(value)
        except (TypeError, ValueError):
            return None

    @staticmethod
    def _parse_timeleft(value) -> Optional[int]:
        if not value or not isinstance(value, str):
            return None
        parts = value.split(':')
        try:
            if len(parts) == 3:
                h, m, s = parts
                return int(h) * 3600 + int(m) * 60 + int(s)
            if len(parts) == 2:
                m, s = parts
                return int(m) * 60 + int(s)
        except ValueError:
            return None
        return None

    async def remove(self, job_id: str, delete_files: bool = False) -> bool:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._remove_sync, job_id, delete_files)

    def _remove_sync(self, job_id: str, delete_files: bool) -> bool:
        # SAB deletes from queue or history depending on where the job is.
        # We try queue first; if SAB reports no-op, fall through to history.
        params = {'name': 'delete', 'value': job_id}
        if delete_files:
            params['del_files'] = 1
        data = self._call_sync('queue', **params)
        if data and data.get('status'):
            return True
        # History delete
        history_params = {'name': 'delete', 'value': job_id}
        if delete_files:
            history_params['del_files'] = 1
        data = self._call_sync('history', **history_params)
        return bool(data and data.get('status'))

    async def pause(self, job_id: str) -> bool:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._pause_sync, job_id)

    def _pause_sync(self, job_id: str) -> bool:
        data = self._call_sync('queue', name='pause', value=job_id)
        return bool(data and data.get('status'))

    async def resume(self, job_id: str) -> bool:
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._resume_sync, job_id)

    def _resume_sync(self, job_id: str) -> bool:
        data = self._call_sync('queue', name='resume', value=job_id)
        return bool(data and data.get('status'))
