#!python
"""
MPD What - A tool for fetching album art, displaying track info, and scrobbling
for Music Player Daemon (MPD).
"""
import os
import yaml
import random
import unicodedata
import json
from time import time, ctime, mktime, localtime
from sys import argv, stderr
from pathlib import Path
from urllib.parse import urlparse
from io import BytesIO
from dataclasses import dataclass
from typing import Optional, List, Tuple

from latest_user_agents import get_random_user_agent
from mpd import MPDClient
import pycurl
import magic
import discogs_client

try:
    import pylast
    LASTFM_AVAILABLE = True
except ImportError:
    LASTFM_AVAILABLE = False


@dataclass
class Config:
    """Configuration for MPD What."""
    config_dir: Path
    coverart_dir: Path
    lastplayed_file: Path
    playlist_file: Path
    error_log: Path
    mpd_host: str = "localhost"
    mpd_port: str = "6600"
    discogs_token: str = ""
    lastfm_user: str = ""
    lastfm_pass: str = ""
    lastfm_api_key: str = ""
    lastfm_api_secret: str = ""
    librefm_user: str = ""
    librefm_pass: str = ""
    showinfo: bool = True
    download: bool = False
    force_download: bool = False
    scrobble: bool = False

    @classmethod
    def load(cls, config_path: Optional[Path] = None) -> 'Config':
        """Load configuration from file or create default."""
        if config_path is None:
            config_path = Path.home() / '.config' / 'mpd_what'
        
        config_path = Path(config_path).expanduser()
        
        # Default paths
        config = cls(
            config_dir=config_path,
            coverart_dir=Path.home() / 'Pictures' / 'coverart',
            lastplayed_file=config_path / 'last',
            playlist_file=config_path / 'playlist.txt',
            error_log=config_path / 'log'
        )
        
        config_file = config_path / 'config.yml'
        
        if config_file.exists():
            try:
                with open(config_file, 'r') as f:
                    cfg = yaml.load(f, Loader=yaml.BaseLoader)
                    
                # Update config with values from file
                for key, value in cfg.items():
                    if hasattr(config, key):
                        if key == 'coverart_dir':
                            setattr(config, key, Path(value).expanduser())
                        else:
                            setattr(config, key, value)
                            
            except (yaml.YAMLError, yaml.scanner.ScannerError) as e:
                config.log_error("Error parsing config file: " + str(e))
                print("Error parsing your config file. Check ~/.config/mpd_what/log", 
                      file=stderr)
        else:
            # Create default config file
            config_path.mkdir(parents=True, exist_ok=True)
            default_config = {
                'coverart_dir': str(config.coverart_dir),
                'mpd_host': config.mpd_host,
                'mpd_port': config.mpd_port,
                'discogs_token': 'please_sign_up_at_discogs_and_generate_a_token'
            }
            with open(config_file, 'w') as f:
                yaml.dump(default_config, f, default_flow_style=False)
        
        # Ensure coverart directory exists
        config.coverart_dir.mkdir(parents=True, exist_ok=True)
        
        return config
    
    def log_error(self, message: str) -> None:
        """Log an error message with timestamp."""
        try:
            self.error_log.parent.mkdir(parents=True, exist_ok=True)
            with open(self.error_log, 'a') as f:
                timestamp = ctime(time())
                f.write(str(timestamp) + ': ' + message + '\n')
        except Exception as e:
            print("Failed to write to error log: " + str(e), file=stderr)


class MPDInfo:
    """Handle MPD connection and current song information."""
    
    def __init__(self, config: Config):
        self.config = config
    
    def get_current_song(self) -> Optional[dict]:
        """Get currently playing song from MPD."""
        try:
            client = MPDClient()
            client.connect(self.config.mpd_host, self.config.mpd_port)
            
            status = client.status()
            if status['state'] == 'stop':
                client.close()
                client.disconnect()
                return None
            
            current_song = client.currentsong()
            client.close()
            client.disconnect()
            
            return current_song
        except Exception as e:
            self.config.log_error("Error connecting to MPD: " + str(e))
            return None


class CoverArtManager:
    """Handle album art downloading and management."""
    
    def __init__(self, config: Config):
        self.config = config
        self.user_agent = get_random_user_agent()
    
    @staticmethod
    def remove_accents(text: str) -> str:
        """Remove accents from unicode string."""
        nfkd_form = unicodedata.normalize('NFKD', text)
        return nfkd_form.encode('ASCII', 'ignore').decode()
    
    @staticmethod
    def prepare_query(parts: List[str]) -> str:
        """Prepare search query from artist/album/song parts."""
        query = ''.join(parts)
        query = query.replace(' ', '+').replace('/', '+').replace('&', '')
        query = query.replace('++', '+')
        query = CoverArtManager.remove_accents(query)
        return query
    
    def download_url(self, url: str, as_text: bool = False) -> Optional[bytes]:
        """Download content from URL using pycurl."""
        buffer = BytesIO()
        curl = pycurl.Curl()
        
        try:
            curl.setopt(curl.FOLLOWLOCATION, 1)
            curl.setopt(curl.USERAGENT, self.user_agent)
            curl.setopt(curl.URL, url)
            curl.setopt(curl.WRITEDATA, buffer)
            curl.perform()
            curl.close()
            
            data = buffer.getvalue()
            buffer.close()
            
            if as_text:
                return data.decode('utf-8', errors='ignore')
            return data
            
        except Exception as e:
            self.config.log_error("Error downloading " + url + ": " + str(e))
            return None
    
    @staticmethod
    def is_image(filepath: Path) -> bool:
        """Check if file is a valid image."""
        try:
            return 'image' in magic.from_file(str(filepath), mime=True)
        except Exception:
            return False
    
    def set_cover_symlink(self, filepath: Path) -> bool:
        """Create symlink to current cover art."""
        display_path = self.config.coverart_dir / 'cover.jpg'
        
        try:
            if display_path.is_symlink() or display_path.exists():
                if display_path.is_symlink() and display_path.resolve() == filepath:
                    return True
                display_path.unlink()
            
            display_path.symlink_to(filepath)
            return True
        except Exception as e:
            self.config.log_error("Error creating symlink: " + str(e))
            return False
    
    def get_coverart(self, filename: str, url: str = "") -> bool:
        """Download and set cover art."""
        # Determine full path
        if '/' in filename:
            filepath = Path(filename)
        else:
            filepath = self.config.coverart_dir / filename
        
        # Check if file already exists and is valid
        if filepath.exists() and self.is_image(filepath):
            return self.set_cover_symlink(filepath)
        
        # Download if URL provided
        if url:
            try:
                data = self.download_url(url)
                if data:
                    filepath.parent.mkdir(parents=True, exist_ok=True)
                    with open(filepath, 'wb') as f:
                        f.write(data)
                    
                    if self.is_image(filepath):
                        return self.set_cover_symlink(filepath)
                    else:
                        filepath.unlink()
                        return False
            except Exception as e:
                self.config.log_error("Error saving cover art: " + str(e))
                return False
        
        return False
    
    def fetch_from_discogs(self, parts: List[str]) -> bool:
        """Fetch cover art from Discogs."""
        if not self.config.discogs_token:
            return False
        
        query = self.prepare_query(parts)
        filepath = self.config.coverart_dir / (query + ".jpg")
        
        if filepath.exists():
            return self.get_coverart(str(filepath))
        
        try:
            client = discogs_client.Client(
                'mpd_what/3.0',
                user_token=self.config.discogs_token
            )
            
            if len(parts) >= 2:
                results = client.search(parts[1], artist=parts[0], type='release')
            else:
                results = client.search(parts[0], type='release')
            
            if results.count > 0:
                first_result = results.page(1)[0]
                if hasattr(first_result, 'images') and first_result.images:
                    url = first_result.images[0]['uri']
                    return self.get_coverart(str(filepath), url)
        except Exception as e:
            self.config.log_error("Error fetching from Discogs: " + str(e))
        
        # Fallback to generic icon
        return self.get_coverart(
            "generic_lp_icon.jpg",
            "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/LP_Vinyl_Symbol_Icon.png/240px-LP_Vinyl_Symbol_Icon.png"
        )
    
    def get_station_info(self, station: str, dl: bool) -> List[str]:
        """Get currently playing information from various internet radio stations."""
        artist = ""
        album = ""
        song = ""
        
        try:
            station = station.lower()
            
            if station == 'source':
                # some internet radio stations have the weirdest habits
                mpd_info = MPDInfo(self.config)
                current = mpd_info.get_current_song()
                if current:
                    station = current.get('file', '')
            
            if 'kexp' in station:
                url = "https://legacy-api.kexp.org/play/?format=json&limit=1&ordering=-airdate"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    if str(data["results"][0]["playtype"]["name"]) == '"Air break"':
                        artist = "KEXP"
                        song = "Air Break"
                    else:
                        artist = str(data["results"][0]["artist"]["name"])
                        album = str(data["results"][0]["release"]["name"])
                        song = str(data["results"][0]["track"]["name"])
            
            elif 'kusf' in station or 'no name' in station:
                url = "http://www.kusf.org/api/broadcasting"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["Track"]["artist"]).replace('"', '')
                    album = str(data["Track"]["album"]).replace('"', '')
                    song = str(data["Track"]["title"]).replace('"', '')
                    if album == " ":
                        album = song
                        song = ""
            
            elif 'kalw' in station:
                url = "https://api.composer.nprstations.org/v1/widget/51827f44e1c8e597ac3f8461/now?format=json&style=v2&show_song=true"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = "KALW"
                    album = str(data["Track"]["name"]).replace('"', '')
            
            elif 'kcrw' in station:
                if 'kcrw live' in station:
                    channel = "Simulcast"
                elif 'e24' in station:
                    channel = "Music"
                else:
                    channel = "Simulcast"
                
                url = 'http://tracklist-api.kcrw.com/' + channel + '/all/1?page_size=1&callback=$.KCRW.refresh_tracklist'
                onair = self.download_url(url, as_text=True)
                if onair:
                    # the kcrw "json" is not valid json until the first and last line are removed
                    onair = '\n'.join(onair.splitlines()[1:-1])
                    data = json.loads(onair)
                    artist = str(data["artist"])
                    album = str(data["album"])
                    song = str(data["title"])
                    if dl:
                        coverart_url = str(data["albumImageLarge"])
                        fname = '+'.join((artist, album)).replace(' ', '+')
                        self.get_coverart(fname, coverart_url)
            
            elif 'mountain chill' in station:
                url = 'https://api.live365.com/v1/station/b58063'
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["current-track"]["artist"])
                    song = str(data["current-track"]["title"].replace("';StreamURL='", ''))
            
            elif 'wfmu' in station:
                url = 'https://wfmu.org/wp-content/themes/wfmu-theme/library/php/includes/liveNow.php'
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["artist"]).replace('"', '')
                    song = str(data["song"]).replace('"', '')
                    album = str(data["album"]).replace('"', '')
                    if dl:
                        coverart_url = str(data["image"]["url"])
                        fname = '+'.join((artist, album)).replace(' ', '+')
                        self.get_coverart(fname, coverart_url)
            
            elif 'somafm' in station:
                url = None
                if 'sonic universe' in station:
                    url = "https://somafm.com/songs/sonicuniverse.json"
                elif 'suburbs of goa' in station:
                    url = "https://somafm.com/songs/suburbsofgoa.json"
                elif 'digitalis' in station:
                    url = "https://somafm.com/songs/digitalis.json"
                elif 'live' in station:
                    url = "https://somafm.com/songs/live.json"
                elif 'underground eighties' in station:
                    url = "https://somafm.com/songs/u80s.json"
                elif 'thistle' in station:
                    url = "https://somafm.com/songs/thistle.json"
                elif 'fluid' in station:
                    url = "https://somafm.com/songs/fluid.json"
                elif 'poptron' in station:
                    url = "https://somafm.com/songs/poptron.json"
                elif 'bagel radio' in station:
                    url = "https://somafm.com/songs/bagel.json"
                elif 'beat blender' in station:
                    url = "https://somafm.com/songs/beatblender.json"
                elif 'groove salad' in station:
                    url = "https://somafm.com/songs/groovesalad.json"
                elif 'indie pop rocks' in station:
                    url = "https://somafm.com/songs/indiepoprocks.json"
                elif 'lush' in station:
                    url = "https://somafm.com/songs/lush.json"
                elif 'boot liquor' in station:
                    url = "https://somafm.com/songs/bootliquor.json"
                elif 'folk forward' in station:
                    url = "https://somafm.com/songs/folkfwd.json"
                elif 'ill street' in station:
                    url = "https://somafm.com/songs/illstreet.json"
                elif 'seven inch soul' in station:
                    url = "https://somafm.com/songs/7soul.json"
                elif 'left coast 70s' in station:
                    url = "https://somafm.com/songs/seventies.json"
                
                if url:
                    onair = self.download_url(url, as_text=True)
                    if onair:
                        data = json.loads(onair)
                        artist = str(data['songs'][0]['artist']).replace('"', '')
                        album = str(data['songs'][0]['album']).replace('"', '')
                        song = str(data['songs'][0]['title']).replace('"', '')
            
            elif 'wwoz' in station:
                epoch = mktime(localtime())
                url = "https://www.wwoz.org/api/schedule/episode/one/at/" + str(int(epoch))
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = "WWOZ"
                    album = data["showhost"]["name"]
                    song = str(data["title"])
            
            elif 'nts live' in station:
                url = "https://www.nts.live/api/v2/live"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["results"][0]["now"]["broadcast_title"]).replace('"', '')
                    album = "NTS"
                    if dl:
                        coverart_url = str(data["results"][0]["now"]["embeds"]["details"]["media"]["background_medium"]).replace('"', '')
                        fname = '+'.join((artist, album)).replace(' ', '+')
                        self.get_coverart(fname, coverart_url)
            
            elif 'kutx' in station:
                url = "https://api.composer.nprstations.org/v1/widget/50ef24ebe1c8a1369593d032/now?format=json"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["onNow"]["song"]["artistName"]).replace('"', '')
                    album = str(data["onNow"]["song"]["collectionName"]).replace('"', '')
                    song = str(data["onNow"]["song"]["trackName"]).replace('"', '')
                    if artist == album == song == 'None':
                        album = str(data["onNow"]["program"]["name"]).replace('"', '')
                    if dl:
                        coverart_url = data["onNow"]["song"]["artworkUrl100"].replace('"', '').replace('100x100', '300x300')
                        fname = '+'.join((artist, album)).replace(' ', '+')
                        self.get_coverart(fname, coverart_url)
            
            elif 'xray' in station:
                url = "https://xray.fm/api/tracks/current"
                onair = self.download_url(url, as_text=True)
                if onair:
                    data = json.loads(onair)
                    artist = str(data["artist"])
                    album = str(data["album"])
                    song = str(data["title"])
            
            else:
                # Most internet radios show the artist and album as
                # artist - album in mpd, so this is a "best guess"
                mpd_info = MPDInfo(self.config)
                current = mpd_info.get_current_song()
                if current and 'title' in current:
                    info = current['title'].split(' - ')
                    if len(info) == 1:
                        artist = info[0]
                    elif len(info) == 2:
                        artist, album = info
                    elif len(info) == 3:
                        artist, album, song = info
        
        except Exception as e:
            self.config.log_error("Error in get_station_info: " + str(e))
        
        return [artist, album, song]


class TrackInfo:
    """Handle track information extraction and parsing."""
    
    def __init__(self, config: Config, cover_art_manager):
        self.config = config
        self.cover_art_manager = cover_art_manager
    
    def parse_mpd_info(self, mpd_data: dict, dl: bool = False) -> List[str]:
        """Extract artist, album, song from MPD data."""
        # Direct track information
        if 'artist' in mpd_data:
            artist = mpd_data['artist']
            album = mpd_data.get('album', '')
            song = mpd_data.get('title', '')
            return [artist, album, song] if album else [artist, song]
        
        # Radio station with name
        if 'name' in mpd_data:
            station = mpd_data['name']
            return self.cover_art_manager.get_station_info(station, dl)
        
        # Radio station from file URL
        if 'file' in mpd_data:
            file_path = mpd_data['file']
            if file_path.startswith(('http://', 'https://')):
                # Extract domain name from URL
                fqdn = urlparse(file_path).netloc
                dn = ('.'.join(fqdn.split('.')[-2:]))
                # sometimes (perhaps, often), the url includes a port number. We don't want that.
                dn = (dn.split(':')[0:])[0]
                return self.cover_art_manager.get_station_info(dn, dl)
            else:
                # Local file path
                parts = file_path.split('/')
                if len(parts) >= 3:
                    return parts[:3]
                return parts
        
        return []
    
    @staticmethod
    def _parse_station_title(title: str) -> List[str]:
        """Parse radio station title into artist/album/song."""
        if not title:
            return []
        
        parts = title.split(' - ')
        return [p.strip() for p in parts if p.strip()]


class Scrobbler:
    """Handle Last.fm and Libre.fm scrobbling."""
    
    def __init__(self, config: Config):
        self.config = config
        self.lastfm_network = None
        self.librefm_network = None
        
        if not LASTFM_AVAILABLE:
            return
        
        # Initialize networks if credentials are provided
        if all([config.lastfm_user, config.lastfm_pass, 
                config.lastfm_api_key, config.lastfm_api_secret]):
            try:
                self.lastfm_network = pylast.LastFMNetwork(
                    api_key=config.lastfm_api_key,
                    api_secret=config.lastfm_api_secret,
                    username=config.lastfm_user,
                    password_hash=pylast.md5(config.lastfm_pass)
                )
            except Exception as e:
                config.log_error("Error initializing Last.fm: " + str(e))
        
        if all([config.librefm_user, config.librefm_pass,
                config.lastfm_api_key, config.lastfm_api_secret]):
            try:
                self.librefm_network = pylast.LibreFMNetwork(
                    api_key=config.lastfm_api_key,
                    api_secret=config.lastfm_api_secret,
                    username=config.librefm_user,
                    password_hash=pylast.md5(config.librefm_pass)
                )
            except Exception as e:
                config.log_error("Error initializing Libre.fm: " + str(e))
    
    def scrobble(self, artist: str, title: str, album: str = "") -> None:
        """Scrobble track to Last.fm and Libre.fm."""
        if not LASTFM_AVAILABLE:
            return
        
        timestamp = int(time())
        
        for network, name in [(self.lastfm_network, "Last.fm"), 
                               (self.librefm_network, "Libre.fm")]:
            if network:
                try:
                    if album:
                        network.scrobble(
                            artist=artist,
                            album=album,
                            title=title,
                            timestamp=timestamp
                        )
                    else:
                        network.scrobble(
                            artist=artist,
                            title=title,
                            timestamp=timestamp
                        )
                except Exception as e:
                    self.config.log_error(
                        "Error scrobbling to " + name + ": " + str(e) + "\n" +
                        "Artist: " + artist + ", Album: " + album + ", Title: " + title
                    )


class MPDWhat:
    """Main application controller."""
    
    def __init__(self, config: Config):
        self.config = config
        self.mpd_info = MPDInfo(config)
        self.cover_art = CoverArtManager(config)
        self.track_info = TrackInfo(config, self.cover_art)
        self.scrobbler = Scrobbler(config)
    
    def get_last_played(self) -> str:
        """Read last played track info."""
        if self.config.lastplayed_file.exists():
            try:
                return self.config.lastplayed_file.read_text().strip()
            except Exception:
                return ""
        return ""
    
    def save_last_played(self, info: str) -> None:
        """Save currently playing track info."""
        try:
            self.config.lastplayed_file.parent.mkdir(parents=True, exist_ok=True)
            self.config.lastplayed_file.write_text(info)
        except Exception as e:
            self.config.log_error("Error saving last played: " + str(e))
    
    def save_to_playlist(self, info: str) -> None:
        """Append track to playlist history."""
        try:
            self.config.playlist_file.parent.mkdir(parents=True, exist_ok=True)
            timestamp = ctime(time())
            oneline_info = info.replace('\n', ';')
            
            with open(self.config.playlist_file, 'a') as f:
                f.write(timestamp + ': ' + oneline_info + '\n')
        except Exception as e:
            self.config.log_error("Error saving to playlist: " + str(e))
    
    def run(self) -> None:
        """Main execution loop."""
        current_song = self.mpd_info.get_current_song()
        
        if not current_song:
            return
        
        # Parse track information
        track_parts = self.track_info.parse_mpd_info(current_song, self.config.download)
        
        # Filter empty strings
        track_parts = [p for p in track_parts if p]
        
        if not track_parts:
            return
        
        info_string = '\n'.join(track_parts)
        last_played = self.get_last_played()
        
        # Handle forced download
        if self.config.force_download:
            self.cover_art.fetch_from_discogs(track_parts)
            return
        
        # Check if track has changed
        if info_string == last_played:
            if self.config.showinfo:
                print(info_string)
            return
        
        # Download cover art if requested
        if self.config.download:
            self.cover_art.fetch_from_discogs(track_parts)
        
        # Process track change
        if len(track_parts) >= 2:
            artist = track_parts[0]
            album = track_parts[1] if len(track_parts) >= 3 else ""
            title = track_parts[-1]
            
            # Don't save if it's a URL (streaming)
            if "http" not in artist:
                self.save_last_played(info_string)
                self.save_to_playlist(info_string)
                
                # Scrobble if enabled
                if self.config.scrobble:
                    self.scrobbler.scrobble(artist, title, album)
        
        # Display info
        if self.config.showinfo:
            print(info_string)


def print_help(program_name: str) -> None:
    """Print help message."""
    print("usage:\n" +
          "    " + program_name + " -sc  or --scrobble to try to scrobble music to last.fm or libre.fm\n" +
          "    " + program_name + " -g   or --get to download album art\n" +
          "    " + program_name + " -q   or --quiet to not print info\n" +
          "    " + program_name + " --force-download to force re-download of cover art\n" +
          "    \n" +
          "arguments can be composed, e.g.:\n" +
          "    " + program_name + " -sc -g -q")


def parse_arguments(args: List[str]) -> Config:
    """Parse command line arguments and update config."""
    config = Config.load()
    
    valid_args = {'-g', '--get', '-sc', '--scrobble', '-h', '--help', 
                  '-q', '--quiet', '--force-download'}
    
    # Validate arguments
    for arg in args[1:]:
        if arg.lower() not in valid_args:
            print_help(args[0])
            exit(1)
    
    # Process arguments
    if '-h' in args or '--help' in args:
        print_help(args[0])
        exit(0)
    
    if '-g' in args or '--get' in args:
        config.download = True
    
    if '--force-download' in args:
        config.force_download = True
    
    if '-q' in args or '--quiet' in args:
        config.showinfo = False
    
    if '-sc' in args or '--scrobble' in args:
        config.scrobble = True
    
    return config


def main():
    """Main entry point."""
    config = parse_arguments(argv)
    app = MPDWhat(config)
    app.run()


if __name__ == '__main__':
    main()
