#!/usr/bin/env python3
"""
Metadata Editor Dialog for neo-pod-desktop
Allows editing tags and cover art of audio files (MP3, M4A, FLAC, OGG, OPUS).
Supports single-track and batch (multi-track) editing.
"""

import os
import base64
import struct
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from io import BytesIO

from PIL import Image
from mutagen import File as MutagenFile
from mutagen.mp4 import MP4
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus

from PyQt6.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
    QPushButton, QLabel, QLineEdit, QSpinBox, QCheckBox,
    QFileDialog, QMessageBox, QFrame, QDialogButtonBox,
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent

from metadata_handler import MetadataHandler
from artwork.cache import cover_cache
from services.itunes_search import search_itunes
from library_cache import get_library_cache, save_library_cache
from .star_rating import ClickableStarRating


COVER_SIZE = 250
SENTINEL_MULTIPLE = "__MULTIPLE__"


class MetadataEditorDialog(QDialog):
    """Dialog for viewing and editing audio file metadata and cover art."""

    def __init__(self, file_paths: List[str], parent=None):
        super().__init__(parent)
        self.file_paths = file_paths
        self._batch_mode = len(file_paths) > 1
        self._current_file = file_paths[0] if file_paths else ""
        self._original_tags: Dict[str, Any] = {}
        self._mixed_fields: set = set()
        self._new_cover_path: Optional[str] = None
        self._cover_removed = False
        self._existing_cover_data: Optional[bytes] = None

        title = f"Edit Metadata ({len(file_paths)} files)" if self._batch_mode else "Edit Metadata"
        self.setWindowTitle(title)
        self.setMinimumSize(620, 440)
        self.setModal(True)

        self._load_tags()
        self._setup_ui()
        self._populate_form()

        if self._batch_mode:
            self._apply_batch_mode()

    def _load_tags(self):
        """Load metadata from file(s). In batch mode, finds common values."""
        if not self.file_paths:
            return

        first_tags = self._load_single_file(self.file_paths[0])
        self._original_tags = dict(first_tags)
        self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0])

        self._load_ratings_from_cache()

        if self._batch_mode:
            all_tags = [first_tags]
            all_different = False
            for path in self.file_paths[1:]:
                tags = self._load_single_file(path, skip_cover=True)
                all_tags.append(tags)
                if all_different:
                    continue
                for key in list(self._original_tags.keys()):
                    if tags.get(key) != self._original_tags.get(key):
                        self._mixed_fields.add(key)

    def _load_ratings_from_cache(self):
        """Load rating from LibraryCache for all selected files.

        Stores the rating (0–5 stars) in ``self._original_tags['rating']``
        (only when cache has a value; file‑tag rating takes precedence if
        cache is empty).  In batch mode, marks ``rating`` as mixed when
        values differ.
        """
        cache = get_library_cache()
        ratings = []
        for p in self.file_paths:
            entry = cache.get(p)
            r = entry.get("rating", 0) if entry else 0
            ratings.append(max(0, min(5, round(r / 20))))

        cache_val = ratings[0]
        if cache_val > 0 or "rating" not in self._original_tags:
            self._original_tags["rating"] = cache_val

        if self._batch_mode and len(set(ratings)) > 1:
            self._mixed_fields.add("rating")
            self._ratings = {p: r for p, r in zip(self.file_paths, ratings)}
        else:
            self._ratings = {}

    def _load_single_file(self, file_path: str, skip_cover: bool = False) -> Dict[str, Any]:
        """Load tags from a single file. Returns dict of tag_key -> value."""
        result: Dict[str, Any] = {}
        try:
            audio = MutagenFile(file_path)
            if audio is None:
                return result
            tags = audio.tags

            ext = Path(file_path).suffix.lower()

            field_map = {
                'title':    ('title',    '\xa9nam'),
                'artist':   ('artist',   '\xa9ART'),
                'album':    ('album',    '\xa9alb'),
                'album_artist': ('albumartist', 'aART'),
                'genre':    ('genre',    '\xa9gen'),
                'date':     ('date',     '\xa9day'),
                'comment':  ('comment',  '\xa9cmt'),
            }
            for our_key, (easy_key, mp4_key) in field_map.items():
                val = self._safe_get(tags, easy_key, mp4_key)
                if val is not None:
                    result[our_key] = val

            result.update(self._load_track_disc(tags))
            result.update(self._load_rating_from_tags(tags, ext))

        except Exception:
            pass

        return result

    @staticmethod
    def _load_rating_from_tags(tags, ext: str) -> dict:
        """Read rating (0–5 stars) from audio file tags, if present."""
        if tags is None:
            return {}
        try:
            if ext in ('.m4a', '.aac', '.mp4') and '\xa9rt' in tags:
                rt = tags['\xa9rt']
                if isinstance(rt, list) and rt:
                    val = int(rt[0]) // 20  # 0-100 → 0-5
                    return {"rating": max(0, min(5, val))}
            elif ext == '.mp3':
                for frame in tags.getall('POPM'):
                    if frame.rating > 0:
                        pops = [0, 64, 128, 192, 224, 255]
                        stars = max(i for i, v in enumerate(pops) if v <= frame.rating)
                        return {"rating": stars}
            elif ext in ('.flac', '.ogg', '.opus') and 'RATING' in tags:
                raw = tags['RATING']
                if isinstance(raw, list):
                    raw = raw[0]
                val = int(str(raw)) // 20
                return {"rating": max(0, min(5, val))}
        except Exception:
            pass
        return {}

    def _load_track_disc(self, tags) -> Dict[str, Any]:
        result = {}
        if tags is None:
            return result

        if 'trkn' in tags:
            trkn = tags['trkn']
            if isinstance(trkn, list) and trkn:
                t = trkn[0]
                if isinstance(t, (tuple, list)) and len(t) >= 1:
                    result['track_number'] = int(t[0]) if t[0] else 0
                    if len(t) >= 2:
                        result['track_total'] = int(t[1]) if t[1] else 0
        elif 'TRCK' in tags:
            tn, tt = self._parse_slash(str(tags['TRCK']))
            if tn:
                result['track_number'] = tn
            if tt:
                result['track_total'] = tt
        elif hasattr(tags, 'getall'):
            try:
                frames = tags.getall('TRCK')
                if frames:
                    tn, tt = self._parse_slash(str(frames[0]))
                    if tn:
                        result['track_number'] = tn
                    if tt:
                        result['track_total'] = tt
            except Exception:
                pass

        for mp4_key, (vorbis_key, num_key, total_key) in (
            ('disk', ('DISCNUMBER', 'disc_number', 'disc_total')),
        ):
            if mp4_key in tags:
                disk_val = tags[mp4_key]
                if isinstance(disk_val, list) and disk_val:
                    d = disk_val[0]
                    if isinstance(d, (tuple, list)) and len(d) >= 1:
                        result[num_key] = int(d[0]) if d[0] else 0
                        if len(d) >= 2:
                            result[total_key] = int(d[1]) if d[1] else 0
            elif vorbis_key in tags:
                dn, dt = self._parse_slash(str(tags[vorbis_key]))
                if dn:
                    result[num_key] = dn
                if dt:
                    result[total_key] = dt
            elif hasattr(tags, 'getall'):
                try:
                    frames = tags.getall('TPOS')
                    if frames:
                        dn, dt = self._parse_slash(str(frames[0]))
                        if dn:
                            result[num_key] = dn
                        if dt:
                            result[total_key] = dt
                except Exception:
                    pass

        return result

    def _extract_cover_from_path(self, file_path: str) -> Optional[bytes]:
        try:
            audio = MutagenFile(file_path)
            if audio is None:
                return None
            return self._extract_cover_raw(audio, audio.tags)
        except Exception:
            return None

    def _safe_get(self, tags, easy_key: str, mp4_key: str) -> Optional[str]:
        if tags is None:
            return None
        try:
            if mp4_key in tags:
                val = tags[mp4_key]
                if isinstance(val, list) and val:
                    return str(val[0])
                return str(val)
        except (ValueError, KeyError):
            pass
        try:
            easy_lower = easy_key.lower()
            for k in tags.keys():
                if k.lower() == easy_lower:
                    val = tags[k]
                    if isinstance(val, list) and val:
                        return str(val[0])
                    return str(val)
        except (ValueError, AttributeError):
            pass
        id3_map = {
            'title': 'TIT2', 'artist': 'TPE1', 'album': 'TALB',
            'albumartist': 'TPE2', 'genre': 'TCON', 'date': 'TDRC', 'comment': 'COMM',
        }
        frame_id = id3_map.get(easy_key.lower())
        if frame_id and hasattr(tags, 'getall'):
            try:
                frames = tags.getall(frame_id)
                if frames:
                    return str(frames[0])
            except Exception:
                pass
        return None

    def _parse_slash(self, val: str):
        try:
            parts = str(val).split('/')
            first = int(parts[0]) if parts[0].strip().isdigit() else 0
            second = int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else 0
            return first, second
        except Exception:
            return 0, 0

    def _extract_cover_raw(self, audio, tags) -> Optional[bytes]:
        if hasattr(tags, 'getall') and tags is not None:
            try:
                for frame in tags.getall('APIC'):
                    return frame.data
            except Exception:
                pass
        try:
            if tags and 'covr' in tags:
                cover_list = tags['covr']
                if isinstance(cover_list, list) and cover_list:
                    return bytes(cover_list[0])
        except Exception:
            pass
        try:
            if tags and 'metadata_block_picture' in tags:
                pic_data = tags['metadata_block_picture']
                if isinstance(pic_data, list):
                    pic_data = pic_data[0]
                pic_data = str(pic_data)
                decoded = base64.b64decode(pic_data)
                pos = 0
                pos += 4
                mime_len = struct.unpack('>I', decoded[pos:pos + 4])[0]
                pos += 4 + mime_len
                desc_len = struct.unpack('>I', decoded[pos:pos + 4])[0]
                pos += 4 + desc_len
                pos += 20
                return decoded[pos:]
        except Exception:
            pass
        if hasattr(audio, 'pictures') and audio.pictures:
            return audio.pictures[0].data
        return None

    def _setup_ui(self):
        outer = QVBoxLayout(self)
        outer.setSpacing(12)

        body = QHBoxLayout()
        body.setSpacing(16)

        cover_layout = QVBoxLayout()
        cover_layout.setAlignment(Qt.AlignmentFlag.AlignTop)

        self.cover_label = QLabel()
        self.cover_label.setFixedSize(COVER_SIZE, COVER_SIZE)
        self.cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.cover_label.setStyleSheet(
            "QLabel { border: 2px dashed #888; border-radius: 6px; background: palette(window); }"
        )
        self.cover_label.setAcceptDrops(True)
        self.cover_label.setText("Drag cover\nhere\nor click")
        self.cover_label.setToolTip("Click to choose cover image, drag & drop to replace")
        self.cover_label.mousePressEvent = self._on_cover_clicked
        cover_layout.addWidget(self.cover_label)

        self.cover_apply_check = QCheckBox("Apply cover to all selected")
        self.cover_apply_check.setVisible(False)
        self.cover_apply_check.setChecked(True)
        cover_layout.addWidget(self.cover_apply_check)

        clear_btn = QPushButton("Remove Cover")
        clear_btn.clicked.connect(self._on_clear_cover)
        cover_layout.addWidget(clear_btn)

        cover_layout.addStretch()
        body.addLayout(cover_layout)

        form_frame = QFrame()
        form_layout = QFormLayout(form_frame)
        form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
        form_layout.setFieldGrowthPolicy(
            QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow
        )

        self.title_edit = QLineEdit()
        self.title_edit.setPlaceholderText("Track Title")
        self.artist_edit = QLineEdit()
        self.artist_edit.setPlaceholderText("Artist")
        self.album_edit = QLineEdit()
        self.album_edit.setPlaceholderText("Album")
        self.album_artist_edit = QLineEdit()
        self.album_artist_edit.setPlaceholderText("Album Artist")
        self.genre_edit = QLineEdit()
        self.genre_edit.setPlaceholderText("Genre")
        self.comment_edit = QLineEdit()
        self.comment_edit.setPlaceholderText("Comment")

        self.year_spin = QSpinBox()
        self.year_spin.setRange(0, 2100)
        self.year_spin.setSpecialValueText("")

        self.track_num_spin = QSpinBox()
        self.track_num_spin.setRange(0, 999)
        self.track_num_spin.setSpecialValueText("")
        self.track_total_spin = QSpinBox()
        self.track_total_spin.setRange(0, 999)
        self.track_total_spin.setSpecialValueText("")

        self.disc_num_spin = QSpinBox()
        self.disc_num_spin.setRange(0, 99)
        self.disc_num_spin.setSpecialValueText("")
        self.disc_total_spin = QSpinBox()
        self.disc_total_spin.setRange(0, 99)
        self.disc_total_spin.setSpecialValueText("")

        self._title_label = QLabel("Title:")
        form_layout.addRow(self._title_label, self.title_edit)

        self._artist_label = QLabel("Artist:")
        form_layout.addRow(self._artist_label, self.artist_edit)

        self._lookup_btn = QPushButton("\U0001F50D Lookup in iTunes")
        self._lookup_btn.setToolTip("Search iTunes Store for metadata by artist and title")
        self._lookup_btn.clicked.connect(self._on_lookup_clicked)
        self._lookup_btn.setVisible(not self._batch_mode)
        self._lookup_status = QLabel("")
        self._lookup_status.setStyleSheet("color: #888; font-size: 11px; font-style: italic;")
        self._lookup_status.setVisible(False)
        lookup_row = QHBoxLayout()
        lookup_row.addWidget(self._lookup_btn)
        lookup_row.addWidget(self._lookup_status)
        lookup_row.addStretch()
        form_layout.addRow(QLabel(""), lookup_row)

        self._album_label = QLabel("Album:")
        form_layout.addRow(self._album_label, self.album_edit)

        self._album_artist_label = QLabel("Album Artist:")
        form_layout.addRow(self._album_artist_label, self.album_artist_edit)

        self._genre_label = QLabel("Genre:")
        form_layout.addRow(self._genre_label, self.genre_edit)

        self.rating_widget = ClickableStarRating()
        self.rating_widget.rating_changed.connect(self._on_rating_changed)
        self._rating_label = QLabel("Rating:")
        form_layout.addRow(self._rating_label, self.rating_widget)

        self._year_label = QLabel("Year:")
        form_layout.addRow(self._year_label, self.year_spin)

        self._track_label = QLabel("Track #:")
        self._track_row_layout = QHBoxLayout()
        self._track_row_layout.addWidget(self.track_num_spin)
        self._track_row_layout.addWidget(QLabel("/"))
        self._track_row_layout.addWidget(self.track_total_spin)
        self._track_row_layout.addStretch()
        form_layout.addRow(self._track_label, self._track_row_layout)

        self._disc_label = QLabel("Disc #:")
        self._disc_row_layout = QHBoxLayout()
        self._disc_row_layout.addWidget(self.disc_num_spin)
        self._disc_row_layout.addWidget(QLabel("/"))
        self._disc_row_layout.addWidget(self.disc_total_spin)
        self._disc_row_layout.addStretch()
        form_layout.addRow(self._disc_label, self._disc_row_layout)

        self._comment_label = QLabel("Comment:")
        form_layout.addRow(self._comment_label, self.comment_edit)

        body.addWidget(form_frame, stretch=1)

        outer.addLayout(body, stretch=1)

        self._track_count_label = QLabel("")
        self._track_count_label.setVisible(False)
        self._track_count_label.setStyleSheet("color: #888; font-size: 11px;")
        outer.addWidget(self._track_count_label)

        buttons = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Save |
            QDialogButtonBox.StandardButton.Cancel
        )
        buttons.accepted.connect(self._on_save)
        buttons.rejected.connect(self.reject)
        outer.addWidget(buttons)

    def _apply_batch_mode(self):
        title_text = f"\uD83D\uDCCB Editing {len(self.file_paths)} selected tracks"
        self._track_count_label.setText(title_text)
        self._track_count_label.setVisible(True)

        self._title_label.setText("Title: (hidden in batch)")
        self.title_edit.setVisible(False)

        for w in (self.track_num_spin, self.track_total_spin):
            w.setVisible(False)
        self._track_label.setText("Track #: (hidden in batch)")

        for widget in self.findChildren(QLabel):
            if widget.text() == "/" and widget.parent() in (self._track_row_layout.parent(), None):
                pass

        self.cover_apply_check.setVisible(True)

        self._lookup_btn.setVisible(False)
        self._lookup_status.setVisible(False)

        for key in self._mixed_fields:
            self._set_mixed(key)

        if "rating" in self._mixed_fields:
            self.rating_widget.set_rating(0)
            self._rating_label.setText("Rating: (mixed)")

    def _set_mixed(self, key: str):
        placeholders = {
            'title': "(different values)",
            'artist': "(different values)",
            'album': "(different values)",
            'album_artist': "(different values)",
            'genre': "(different values)",
            'date': "",
            'comment': "(different values)",
            'track_number': 0,
            'track_total': 0,
            'disc_number': 0,
            'disc_total': 0,
        }
        edit_map = {
            'title': self.title_edit,
            'artist': self.artist_edit,
            'album': self.album_edit,
            'album_artist': self.album_artist_edit,
            'genre': self.genre_edit,
            'comment': self.comment_edit,
        }
        spin_map = {
            'date': self.year_spin,
            'track_number': self.track_num_spin,
            'track_total': self.track_total_spin,
            'disc_number': self.disc_num_spin,
            'disc_total': self.disc_total_spin,
        }

        if key in edit_map:
            edit_map[key].setPlaceholderText(placeholders.get(key, "(different values)"))
            edit_map[key].setText("")
        elif key in spin_map:
            spin_map[key].setValue(0)
            spin_map[key].setSpecialValueText("\u2014")

    def _populate_form(self):
        t = self._original_tags

        self.title_edit.setText(t.get('title', ''))
        self.artist_edit.setText(t.get('artist', ''))
        self.album_edit.setText(t.get('album', ''))
        self.album_artist_edit.setText(t.get('album_artist', ''))
        self.genre_edit.setText(t.get('genre', ''))
        self.comment_edit.setText(t.get('comment', ''))

        date_str = t.get('date', '')
        try:
            year = int(str(date_str)[:4])
        except (ValueError, TypeError):
            year = 0
        self.year_spin.setValue(year)

        self.track_num_spin.setValue(t.get('track_number', 0))
        self.track_total_spin.setValue(t.get('track_total', 0))
        self.disc_num_spin.setValue(t.get('disc_number', 0))
        self.disc_total_spin.setValue(t.get('disc_total', 0))

        self.rating_widget.set_rating(t.get("rating", 0))
        self._update_cover_display()

    def _on_rating_changed(self, stars: int) -> None:
        pass  # placeholder for future live-preview

    def _update_cover_display(self):
        if self._cover_removed:
            self.cover_label.setText("No cover")
            self.cover_label.setPixmap(QPixmap())
            return

        if self._batch_mode and not self._new_cover_path and self._existing_cover_data is None:
            self.cover_label.setText("Multiple\ncovers\n\nClick to replace")
            self.cover_label.setPixmap(QPixmap())
            return

        pixmap = None
        if self._new_cover_path and os.path.exists(self._new_cover_path):
            pixmap = QPixmap(self._new_cover_path)
        elif self._existing_cover_data:
            pixmap = QPixmap()
            pixmap.loadFromData(self._existing_cover_data)

        if pixmap and not pixmap.isNull():
            scaled = pixmap.scaled(
                COVER_SIZE - 16, COVER_SIZE - 16,
                Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation,
            )
            self.cover_label.setPixmap(scaled)
        else:
            self.cover_label.setText("No cover")
            self.cover_label.setPixmap(QPixmap())

    def _on_cover_clicked(self, event):
        path, _ = QFileDialog.getOpenFileName(
            self, "Select Cover Image", "",
            "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.webp);;All Files (*)"
        )
        if path:
            self._new_cover_path = path
            self._cover_removed = False
            self._update_cover_display()

    def _on_clear_cover(self):
        self._new_cover_path = None
        self._cover_removed = True
        self._update_cover_display()

    def _on_lookup_clicked(self):
        artist = self.artist_edit.text().strip()
        title = self.title_edit.text().strip()
        if not artist and not title:
            self._show_lookup_status("Enter artist or title first", is_error=True)
            return

        self._lookup_btn.setEnabled(False)
        self._show_lookup_status("Searching iTunes\u2026")

        result = search_itunes(artist=artist, title=title)

        self._lookup_btn.setEnabled(True)

        if not result:
            self._show_lookup_status("No results found", is_error=True)
            return

        self.title_edit.setText(result.get("title", ""))
        self.artist_edit.setText(result.get("artist", ""))
        self.album_edit.setText(result.get("album", ""))
        self.album_artist_edit.setText(result.get("album_artist", ""))
        self.genre_edit.setText(result.get("genre", ""))
        year = result.get("year", 0)
        if year and year > 0:
            self.year_spin.setValue(year)
        track_num = result.get("track_number", 0)
        if track_num:
            self.track_num_spin.setValue(track_num)
        track_total = result.get("track_total", 0)
        if track_total:
            self.track_total_spin.setValue(track_total)
        disc_num = result.get("disc_number", 0)
        if disc_num:
            self.disc_num_spin.setValue(disc_num)
        disc_total = result.get("disc_total", 0)
        if disc_total:
            self.disc_total_spin.setValue(disc_total)

        cover_url = result.get("cover_url", "")
        if cover_url and not self._existing_cover_data and not self._new_cover_path:
            try:
                import tempfile
                import requests
                resp = requests.get(cover_url, timeout=10)
                resp.raise_for_status()
                fd, tmp_path = tempfile.mkstemp(suffix=".jpg")
                os.close(fd)
                with open(tmp_path, "wb") as f:
                    f.write(resp.content)
                self._new_cover_path = tmp_path
                self._update_cover_display()
            except Exception:
                pass

        self._show_lookup_status("Metadata loaded from iTunes")

    def _show_lookup_status(self, text: str, is_error: bool = False):
        self._lookup_status.setText(text)
        self._lookup_status.setStyleSheet(
            "color: #c0392b; font-size: 11px; font-style: italic;"
            if is_error else
            "color: #27ae60; font-size: 11px; font-style: italic;"
        )
        self._lookup_status.setVisible(True)
        QTimer.singleShot(4000, lambda: self._lookup_status.setVisible(False))

    def dragEnterEvent(self, event: QDragEnterEvent):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event: QDropEvent):
        urls = event.mimeData().urls()
        if urls:
            path = urls[0].toLocalFile()
            if os.path.isfile(path):
                ext = Path(path).suffix.lower()
                if ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp'):
                    self._new_cover_path = path
                    self._cover_removed = False
                    self._update_cover_display()

    def _collect_current(self) -> Dict[str, Any]:
        return {
            'title': self.title_edit.text().strip(),
            'artist': self.artist_edit.text().strip(),
            'album': self.album_edit.text().strip(),
            'album_artist': self.album_artist_edit.text().strip(),
            'genre': self.genre_edit.text().strip(),
            'date': str(self.year_spin.value()) if self.year_spin.value() > 0 else '',
            'track_number': self.track_num_spin.value(),
            'track_total': self.track_total_spin.value(),
            'disc_number': self.disc_num_spin.value(),
            'disc_total': self.disc_total_spin.value(),
            'comment': self.comment_edit.text().strip(),
            'rating': self.rating_widget.rating(),
        }

    def _collect_changes(self) -> Dict[str, Any]:
        if self._batch_mode:
            return self._collect_batch_changes()
        return self._collect_single_changes()

    def _collect_single_changes(self) -> Dict[str, Any]:
        changes = {}
        current = self._collect_current()
        for key, val in current.items():
            orig = self._original_tags.get(key)
            if isinstance(val, int) and isinstance(orig, int):
                if val != orig:
                    changes[key] = val
            elif isinstance(val, int) and isinstance(orig, str):
                try:
                    if int(str(orig)[:4]) != val:
                        changes[key] = val
                except ValueError:
                    if val:
                        changes[key] = val
            elif isinstance(val, int) and orig is None:
                if val != 0:
                    changes[key] = val
            elif str(val) != str(orig or ''):
                changes[key] = val
        return changes

    def _collect_batch_changes(self) -> Dict[str, Any]:
        current = self._collect_current()
        exclude = {'title', 'track_number', 'track_total'}
        changes = {}
        for key, val in current.items():
            if key in exclude:
                continue
            orig = self._original_tags.get(key)
            if key in self._mixed_fields:
                if isinstance(val, int) and val == 0:
                    continue
                if isinstance(val, str) and not val:
                    continue
                changes[key] = val
            else:
                if isinstance(val, int) and isinstance(orig, int):
                    if val != orig:
                        changes[key] = val
                elif isinstance(val, str) and orig is not None:
                    if str(val) != str(orig):
                        changes[key] = val
                elif val and not orig:
                    changes[key] = val

        return changes

    def _on_save(self):
        changes = self._collect_changes()

        cover_arg = None
        if self._cover_removed:
            cover_arg = ''
        elif self._new_cover_path:
            cover_arg = self._new_cover_path

        if not changes and cover_arg is None:
            self.accept()
            return

        paths = self.file_paths if self._batch_mode else [self.file_paths[0]]

        try:
            handler = MetadataHandler()
            failed = 0
            for path in paths:
                ok = handler.update_tags(path, changes, cover_arg)
                if not ok:
                    failed += 1
                else:
                    self._invalidate_cover_cache(path, changes, cover_arg)

            if not failed:
                self._persist_rating(paths, changes)

            if failed == len(paths):
                QMessageBox.warning(self, "Error", "Failed to save metadata changes.")
            elif failed:
                QMessageBox.warning(
                    self, "Partial Success",
                    f"Saved {len(paths) - failed}/{len(paths)} files.\n{failed} file(s) failed."
                )
                self.accept()
            else:
                self.accept()
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{e}")

    def _persist_rating(self, paths: list[str], changes: dict[str, Any]) -> None:
        if "rating" not in changes:
            return
        try:
            cache = get_library_cache()
            for p in paths:
                entry = cache.get(p)
                if entry is None:
                    continue
                entry["rating"] = changes["rating"] * 20
                cache.put(p, entry)
            save_library_cache()
        except Exception:
            logger.exception("Failed to persist rating to LibraryCache")

    def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any],
                                cover_arg: Optional[str]) -> None:
        """Update cover cache after successful save."""
        try:
            audio = MutagenFile(file_path)
            new_cover = self._extract_cover_raw(audio, audio.tags) if audio else None

            artist = changes.get('artist') or self._original_tags.get('artist', 'Unknown Artist')
            album = changes.get('album') or self._original_tags.get('album', '')

            if new_cover:
                cover_cache.put(artist, album, new_cover)
            elif cover_arg == '':
                key = cover_cache._key(artist, album)
                cache_path = os.path.join(cover_cache.cache_dir, f"{key}.jpg")
                if os.path.exists(cache_path):
                    os.remove(cache_path)
        except Exception:
            pass
