# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

from gi.repository import GLib

from enum import IntEnum, auto
from io import BytesIO
import logging
import os
from pathlib import Path
from shutil import copyfile
from threading import Thread
from typing import BinaryIO, NamedTuple, Optional
from urllib.parse import quote

from markdown_it.token import Token

from iotas.attachment import Attachment
from iotas.markdown_helpers import (
    get_image_attachments_from_note_content,
    get_image_attachments_from_tokens,
    filter_image_tokens,
)
from iotas.note import Note

ATTACHMENT_DATA_SUBPATH = "iotas"
ATTACHMENT_DIR_NAME = "attachments"
ATTACHMENT_MAX_SIZE_MB = 50


class AttachmentDiskStates(NamedTuple):
    """Whether attachments exist on disk."""

    exists: dict[str, Attachment]
    """Found on disk"""

    missing: dict[str, Attachment]
    """Missing from disk"""


class AttachmentsCopyOutcome(IntEnum):
    """Outcome on attachments copy to disk path run."""

    NONE = auto()
    """There were no attachments to store"""

    FAILURE = auto()
    """One or more attachments failed to store"""

    HAD_MISSING = auto()
    """One or more attachments were missing on disk"""

    SUCCESS = auto()
    """All attachments were successfully copied"""


class AttachmentsCopyResults(NamedTuple):
    """Outcome and stored attachments for copy to disk path run."""

    outcome: AttachmentsCopyOutcome
    """Run outcome"""

    attachments: list[Attachment] = []
    """Attachments which were stored"""


def copy_note_attachments(note: Note, path: str, prefix_note_id: bool) -> AttachmentsCopyResults:
    """Copy note attachments to a directory.

    :param Note note: Note to work on
    :param str path: Destination directory
    :param bool prefix_note_id: Whether to prefix the note id
    :return: The outcome of the run and attachments which were stored
    :rtype: AttachmentsCopyResults
    """
    attachments = get_image_attachments_from_note_content(note)
    if not attachments:
        return AttachmentsCopyResults(AttachmentsCopyOutcome.NONE)

    if not os.path.exists(path):
        try:
            os.mkdir(path)
        except OSError as e:
            logging.warning(f"Failed to create dir '{path}' to save attachments: {e}")
            return AttachmentsCopyResults(AttachmentsCopyOutcome.FAILURE)

    note_id_prefix = f"{note.id}." if prefix_note_id else ""
    had_failure = False
    had_missing = False
    stored = []

    for attachment in attachments:
        filename = f"{note_id_prefix}{os.path.basename(attachment.path)}"
        if not attachment_exists_locally(attachment):
            logging.warning(f"'{attachment.path}' not available to save")
            had_missing = True
            continue
        dest_path = os.path.join(path, filename)
        try:
            copyfile(get_attachment_filesystem_path(attachment), dest_path)
        except OSError as e:
            logging.warning(f"Failed to save attachment '{attachment.path}': {e}")
            had_failure = True
        else:
            stored.append(attachment)

    if had_failure:
        return AttachmentsCopyResults(AttachmentsCopyOutcome.FAILURE)
    elif had_missing:
        return AttachmentsCopyResults(AttachmentsCopyOutcome.HAD_MISSING, stored)
    else:
        return AttachmentsCopyResults(AttachmentsCopyOutcome.SUCCESS, stored)


def write_attachment_to_disk(attachment: Attachment, data: bytes) -> bool:
    """Write attachment to disk.

    Storing to user attachments directory.

    :param Attachment attachment: Attachment
    :param bytes data: Attachment data
    :return: Success
    :rtype: bool
    """
    path = get_attachment_filesystem_path(attachment)
    if os.path.exists(path):
        logging.warning(f"Attachment '{attachment.filename}' already exists for note?")
        return False

    try:
        with open(get_attachment_filesystem_path(attachment), "wb") as f:
            f.write(data)
    except OSError as e:
        logging.warning(f"Failed to write attachment to disk: {e}")
        return False
    else:
        return True


def get_attachments_dir() -> str:
    """Get user attachments directory.

    :return: Directory
    :rtype: str
    """
    return os.path.join(
        GLib.get_user_data_dir(),
        ATTACHMENT_DATA_SUBPATH,
        ATTACHMENT_DIR_NAME,
    )


def get_attachments_on_disk_for_note(note: Note) -> list[Attachment]:
    """Get the attachments on disk for a note.

    :param Note note: Note to match
    :return: Attachments
    :rtype: list[Attachment]
    """
    attachments = []
    path = Path(get_attachments_dir())
    prefix = f"{note.id}."

    # TODO Python 3.13+ remove exception handling
    try:
        files = list(path.glob(f"{prefix}*"))
    except OSError as e:
        logging.warning(f"Error listing attachments: {e}")
    else:
        for file in files:
            attachment = Attachment()
            attachment.note_id = note.id
            attachment.path = file.name[len(prefix) :]
            attachment.note_remote_id = note.remote_id
            attachments.append(attachment)

    return attachments


def delete_attachments_for_note(note: Note) -> None:
    """Delete attachments from disk for note

    :param Note note: Note to delete for
    """
    for attachment in get_attachments_on_disk_for_note(note):
        if not attachment_exists_locally(attachment):
            continue
        path = get_attachment_filesystem_path(attachment)
        try:
            os.remove(path)
        except OSError as e:
            logging.warning(f"Failed to delete attachment '{path}': {e}")


def get_attachment_disk_states(note: Note, tokens: list[Token]) -> AttachmentDiskStates:
    """Get whether a note's attachments are stored on disk.

    :param Note note: Note to check for
    :param Tokens tokens: Parser tokens to get list of attachments from
    :return: Whether on disk
    :rtype: AttachmentDiskStates
    """
    attachments = get_image_attachments_from_tokens(note, tokens)
    exists = {}
    missing = {}
    for attachment in attachments:
        if attachment_exists_locally(attachment):
            exists[attachment.path_quoted] = attachment
        else:
            missing[attachment.path_quoted] = attachment

    return AttachmentDiskStates(exists, missing)


def get_attachment_filesystem_uri(attachment: Attachment) -> str:
    """Get attachment filesystem path as URI.

    :param Attachment attachment: Attachment
    :return: Path as URI
    :rtype: str
    """
    return "file://" + quote(get_attachment_filesystem_path(attachment))


def get_attachment_filesystem_path(attachment: Attachment) -> str:
    """Get attachment filesystem path.

    :param Attachment attachment: Attachment
    :return: Path
    :rtype: str
    """
    filename = f"{attachment.note_id}.{attachment.filename}"
    return os.path.join(
        get_attachments_dir(),
        filename,
    )


def attachment_exists_locally(attachment: Attachment) -> bool:
    """Get whether attachment exists on disk.

    :param Attachment attachment: Attachment
    :return: Exists
    :rtype: bool
    """
    return os.path.exists(get_attachment_filesystem_path(attachment))


def get_attachment_file_object(attachment: Attachment) -> Optional[BinaryIO]:
    """Get attachment file object.

    :param Attachment attachment: Attachment
    :return: File object
    :rtype: BinaryIO, optional
    """
    content = None
    try:
        with open(get_attachment_filesystem_path(attachment), "rb") as f:
            content = f.read()
    except OSError as e:
        logging.warning(f"Failed to open attachment: {e}")
        return None

    return BytesIO(content)


def flush_all_attachments_on_disk() -> None:
    """Flush all attachments on disk."""
    for file in Path(get_attachments_dir()).iterdir():
        try:
            os.remove(file)
        except OSError as e:
            logging.warning(f"Error removing attachment '{file}': {e}")


def set_attachment_filesystem_uris_on_tokens(
    tokens: list[Token], new_paths: dict[str, Attachment]
) -> None:
    """Update image paths in parser tokens to filesystem URIs.

    :param list[Token] tokens: Parser tokens to update
    :param dict[str, Attachment] new_paths: Dict with (quoted) existing markup paths, attachments
        for filesystem URIs
    """
    images = filter_image_tokens(tokens)
    for img in images:
        src = img.attrs["src"]
        if src in new_paths:
            img.attrs["src"] = get_attachment_filesystem_uri(new_paths[src])


def trim_orphaned_images(note: Note, on_thread: bool) -> None:
    """Trim images on disk which don't appear in the note.

    :param Note note: Note to trim for
    :param bool on_thread: Whether to work on thread
    """

    def work(note: Note) -> None:
        on_disk_paths = [
            get_attachment_filesystem_path(attachment)
            for attachment in get_attachments_on_disk_for_note(note)
        ]
        if on_disk_paths:
            attachments = get_image_attachments_from_note_content(note)
            content_paths = [
                get_attachment_filesystem_path(attachment) for attachment in attachments
            ]
            for path in on_disk_paths:
                if path not in content_paths:
                    filename = os.path.basename(path)
                    logging.debug(
                        f"Deleting attachment no longer in note '{note.title}': '{filename}'"
                    )
                    try:
                        os.remove(path)
                    except OSError as e:
                        logging.warning(f"Error deleting attachment '{filename}': {e}")

    if on_thread:
        thread = Thread(target=work, args=(note,))
        thread.daemon = True
        thread.start()
    else:
        work(note)
