# Standard library imports
import datetime as dt
from email.message import EmailMessage
from typing import Optional
import os
import re
import shutil
import smtplib
import ssl
import tempfile

# PyQt imports
from PyQt6.QtWidgets import QInputDialog, QMainWindow, QMessageBox, QWidget

# Third party imports
from mutagen.flac import FLAC  # type: ignore
from mutagen.mp3 import MP3  # type: ignore
from pydub import AudioSegment, effects
from pydub.utils import mediainfo
from tinytag import TinyTag, TinyTagException  # type: ignore

# App imports
from classes import AudioMetadata, ApplicationError, Tags, TrackDTO
from config import Config
from log import log
from models import Tracks

start_time_re = re.compile(r"@\d\d:\d\d")


def ask_yes_no(
    title: str,
    question: str,
    default_yes: bool = False,
    parent: Optional[QMainWindow] = None,
) -> bool:
    """Ask question; return True for yes, False for no"""

    dlg = QMessageBox(parent)
    dlg.setWindowTitle(title)
    dlg.setText(question)
    dlg.setStandardButtons(
        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
    )
    dlg.setIcon(QMessageBox.Icon.Question)
    if default_yes:
        dlg.setDefaultButton(QMessageBox.StandardButton.Yes)
    button = dlg.exec()

    return button == QMessageBox.StandardButton.Yes


def fade_point(
    audio_segment: AudioSegment,
    fade_threshold: float = 0.0,
    chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
    """
    Returns the millisecond/index of the point where the volume drops below
    the maximum and doesn't get louder again.
    audio_segment - the sdlg_search_database_uiegment to find silence in
    fade_threshold - the upper bound for how quiet is silent in dFBS
    chunk_size - chunk size for interating over the segment in ms
    """

    assert chunk_size > 0  # to avoid infinite loop

    segment_length: int = audio_segment.duration_seconds * 1000  # ms
    trim_ms = segment_length - chunk_size
    max_vol = audio_segment.dBFS
    if fade_threshold == 0:
        fade_threshold = max_vol

    while (
        audio_segment[trim_ms : trim_ms + chunk_size].dBFS < fade_threshold
        and trim_ms > 0
    ):  # noqa W503
        trim_ms -= chunk_size

    # if there is no trailing silence, return lenght of track (it's less
    # the chunk_size, but for chunk_size = 10ms, this may be ignored)
    return int(trim_ms)


def file_is_unreadable(path: Optional[str]) -> bool:
    """
    Returns True if passed path is readable, else False
    """

    if not path:
        return True

    return not os.access(path, os.R_OK)


def get_audio_segment(path: str) -> Optional[AudioSegment]:
    try:
        if path.endswith(".mp3"):
            return AudioSegment.from_mp3(path)
        elif path.endswith(".flac"):
            return AudioSegment.from_file(path, "flac")  # type: ignore
    except AttributeError:
        return None

    return None


def get_embedded_time(text: str) -> Optional[dt.datetime]:
    """Return datetime specified as @hh:mm in text"""

    try:
        match = start_time_re.search(text)
    except TypeError:
        return None
    if not match:
        return None

    try:
        return dt.datetime.strptime(match.group(0)[1:], Config.NOTE_TIME_FORMAT)
    except ValueError:
        return None


def get_all_track_metadata(filepath: str) -> dict[str, str | int | float]:
    """Return all track metadata"""

    return (
        get_audio_metadata(filepath)._asdict()
        | get_tags(filepath)._asdict()
        | dict(path=filepath)
    )


def get_audio_metadata(filepath: str) -> AudioMetadata:
    """Return audio metadata"""

    # Set start_gap, fade_at and silence_at
    audio = get_audio_segment(filepath)
    if not audio:
        return AudioMetadata()
    else:
        return AudioMetadata(
            start_gap=leading_silence(audio),
            fade_at=int(
                round(fade_point(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
            ),
            silence_at=int(
                round(trailing_silence(audio) / 1000, Config.MILLISECOND_SIGFIGS) * 1000
            ),
        )


def get_name(prompt: str, default: str = "") -> str | None:
    """Get a name from the user"""

    dlg = QInputDialog()
    dlg.setInputMode(QInputDialog.InputMode.TextInput)
    dlg.setLabelText(prompt)
    while True:
        if default:
            dlg.setTextValue(default)
        dlg.resize(500, 100)
        ok = dlg.exec()
        if ok:
            return dlg.textValue()

        return None


def get_relative_date(
    past_date: Optional[dt.datetime], now: Optional[dt.datetime] = None
) -> str:
    """
    Return how long before reference_date past_date is as string.

    Params:
        @past_date: datetime
        @reference_date: datetime, defaults to current date and time

        @return: string
    """

    if not past_date or past_date == Config.EPOCH:
        return "Never"
    if not now:
        now = dt.datetime.now()

    # Check parameters
    if past_date > now:
        raise ApplicationError("get_relative_date() past_date is after relative_date")

    delta = now - past_date
    days = delta.days

    if days == 0:
        return "(Today)"
    elif days == 1:
        return "(Yesterday)"

    years, days_remain = divmod(days, 365)
    months, days_final = divmod(days_remain, 30)

    parts = []
    if years:
        parts.append(f"{years}y")
    if months:
        parts.append(f"{months}m")
    if days_final:
        parts.append(f"{days_final}d")
    formatted = " ".join(parts)
    return f"({formatted} ago)"


def get_tags(path: str) -> Tags:
    """
    Return a dictionary of title, artist, bitrate and duration-in-milliseconds.
    """

    try:
        tag = TinyTag.get(path)
    except FileNotFoundError:
        raise ApplicationError(f"File not found: {path}")
    except TinyTagException:
        raise ApplicationError(f"Can't read tags in {path}")

    if (
        tag.title is None
        or tag.artist is None
        or tag.bitrate is None
        or tag.duration is None
    ):
        raise ApplicationError(f"Missing tags in {path}")

    return Tags(
        title=tag.title,
        artist=tag.artist,
        bitrate=round(tag.bitrate),
        duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
    )


def leading_silence(
    audio_segment: AudioSegment,
    silence_threshold: int = Config.DBFS_SILENCE,
    chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
    """
    Returns the millisecond/index that the leading silence ends.
    audio_segment - the segment to find silence in
    silence_threshold - the upper bound for how quiet is silent in dFBS
    chunk_size - chunk size for interating over the segment in ms

    https://github.com/jiaaro/pydub/blob/master/pydub/silence.py
    """

    trim_ms: int = 0  # ms
    assert chunk_size > 0  # to avoid infinite loop
    while audio_segment[
        trim_ms : trim_ms + chunk_size
    ].dBFS < silence_threshold and trim_ms < len(  # noqa W504
        audio_segment
    ):
        trim_ms += chunk_size

    # if there is no end it should return the length of the segment
    return min(trim_ms, len(audio_segment))


def ms_to_mmss(ms: int | None, none: str = "-") -> str:
    """Convert milliseconds to mm:ss"""

    if ms is None:
        return none

    minutes, seconds = divmod(ms // 1000, 60)

    return f"{minutes}:{seconds:02d}"


def normalise_track(path: str) -> None:
    """Normalise track"""

    # Check type
    ftype = os.path.splitext(path)[1][1:]
    if ftype not in ["mp3", "flac"]:
        log.error(
            f"helpers.normalise_track({path}): " f"File type {ftype} not implemented"
        )

    bitrate = mediainfo(path)["bit_rate"]
    audio = get_audio_segment(path)
    if not audio:
        return

    # Get current file gid, uid and permissions
    stats = os.stat(path)
    try:
        # Copy original file
        _, temp_path = tempfile.mkstemp()
        shutil.copyfile(path, temp_path)
    except Exception as err:
        log.debug(f"helpers.normalise_track({path}): err1: {repr(err)}")
        return

    # Overwrite original file with normalised output
    normalised = effects.normalize(audio)
    try:
        normalised.export(path, format=os.path.splitext(path)[1][1:], bitrate=bitrate)
        # Fix up permssions and ownership
        os.chown(path, stats.st_uid, stats.st_gid)
        os.chmod(path, stats.st_mode)
        # Copy tags
        tag_handler: type[FLAC | MP3]
        if ftype == "flac":
            tag_handler = FLAC
        elif ftype == "mp3":
            tag_handler = MP3
        else:
            return
        src = tag_handler(temp_path)
        dst = tag_handler(path)
        for tag in src:
            dst[tag] = src[tag]
        dst.save()
    except Exception as err:
        log.debug(f"helpers.normalise_track({path}): err2: {repr(err)}")
        # Restore original file
        shutil.copyfile(path, temp_path)
    finally:
        if os.path.exists(temp_path):
            os.remove(temp_path)


def remove_substring_case_insensitive(parent_string: str, substring: str) -> str:
    """
    Remove all instances of substring from parent string, case insensitively
    """

    # Convert both strings to lowercase for case-insensitive comparison
    lower_parent = parent_string.lower()
    lower_substring = substring.lower()

    # Initialize the result string
    result = parent_string

    # Continue removing the substring until it's no longer found
    while lower_substring in lower_parent:
        # Find the index of the substring
        index = lower_parent.find(lower_substring)

        # Remove the substring
        result = result[:index] + result[index + len(substring) :]

        # Update the lowercase versions
        lower_parent = result.lower()

    return result


def send_mail(to_addr: str, from_addr: str, subj: str, body: str) -> None:
    # From https://docs.python.org/3/library/email.examples.html

    # Create a text/plain message
    msg = EmailMessage()
    msg.set_content(body)

    msg["Subject"] = subj
    msg["From"] = from_addr
    msg["To"] = to_addr

    # Send the message via SMTP server.
    context = ssl.create_default_context()
    try:
        s = smtplib.SMTP(host=Config.MAIL_SERVER, port=Config.MAIL_PORT)
        if Config.MAIL_USE_TLS:
            s.starttls(context=context)
        if Config.MAIL_USERNAME and Config.MAIL_PASSWORD:
            s.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
        s.send_message(msg)
    except Exception as e:
        print(e)
    finally:
        s.quit()


def set_track_metadata(track: Tracks) -> None:
    """Set/update track metadata in database"""

    audio_metadata = get_audio_metadata(track.path)
    tags = get_tags(track.path)

    for audio_key in AudioMetadata._fields:
        setattr(track, audio_key, getattr(audio_metadata, audio_key))
    for tag_key in Tags._fields:
        setattr(track, tag_key, getattr(tags, tag_key))


def show_OK(title: str, msg: str, parent: Optional[QWidget] = None) -> None:
    """Display a message to user"""

    dlg = QMessageBox(parent)
    dlg.setIcon(QMessageBox.Icon.Information)
    dlg.setWindowTitle(title)
    dlg.setText(msg)
    dlg.setStandardButtons(QMessageBox.StandardButton.Ok)

    _ = dlg.exec()


def show_warning(parent: Optional[QMainWindow], title: str, msg: str) -> None:
    """Display a warning to user"""

    QMessageBox.warning(parent, title, msg, buttons=QMessageBox.StandardButton.Cancel)


def trailing_silence(
    audio_segment: AudioSegment,
    silence_threshold: int = -50,
    chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE,
) -> int:
    """Return fade point from start in milliseconds"""

    return fade_point(audio_segment, silence_threshold, chunk_size)
