# bugs and suggestions: t.me/thrzxd

import re
from typing import Any, List

from org.telegram.tgnet import TLRPC
from java.util import ArrayList, Locale
from base_plugin import BasePlugin, HookResult, HookStrategy
from client_utils import get_last_fragment
from ui.alert import AlertDialogBuilder
from ui.settings import (
    Divider,
    EditText,
    Header,
    Input,
    Switch,
    Text,
)

__id__ = "songlinker"
__name__ = "Song Linker"
__description__ = "Converts a link into hypertext."
__author__ = "@thrzxd • @kotilnya"
__version__ = "1.0.2"
__icon__ = "Konataizumi0_by_fStikBot/6"
__app_version__ = ">=12.5.1"

PATTERNS = [
    r'open\.spotify\.com/(?:track|album|playlist|artist|user)/',
    r'music\.yandex\.(?:com|ru)/(?:album|playlists|artist)/',
    r'music\.apple\.com/(?:profile/|[^/]+/(?:album|song|artist)/)',
    r'music\.youtube\.com/',
    r'(?:on\.)?soundcloud\.com/',
    r'tidal\.com/',
    r'zvuk\.com/',
]

URL_REGEX = re.compile(
    r'(?P<full_url>(?:https?://)?(?:www\.)?(?P<platform>' + '|'.join(PATTERNS) + r')[^\s]+)',
    re.IGNORECASE,
)

GENERIC_URL_REGEX = re.compile(
    r'(?P<full_url>(?:https?://|www\.)[^\s]+|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:/[^\s]*)?)',
    re.IGNORECASE,
)

SEPARATOR = "•"

STRINGS = {
    "en": {
        "settings_header": "Appearance",
        "force_small_preview": "Force small preview",
        "force_small_preview_sub": "Always use compact link preview for music links",
        "swap_order_title": "Swap track and artist",
        "swap_order_sub": "Display 'Artist • Track' instead of 'Track • Artist'",
        "separator_title": "Separator",
        "separator_sub": "Character between track title and artist",
        "separator_divider_hint": "Separator between track title and artist.\nExample: Track {sep} Artist",
        "separator_divider_hint_swapped": "Separator between track title and artist.\nExample: Artist {sep} Track",
        "info_header": "Information",
        "additional_header": "Additional",
        "apply_to_all_links_title": "Apply to all links (experimental)",
        "apply_to_all_links_sub": "Use webpage title as hypertext text for any link",
        "exceptions_hint": "Example: google.com, youtu.be, t.me",
        "exceptions_divider_hint": "Excluded links without https://, separated by commas.",
        "about_title": "Features and limitations",
        "about_dialog_message": (
            "The plugin converts links from music services into a hyperlink with the title and artist.\n\n"
            "Supported platforms: Spotify, Apple Music, Yandex Music, Tidal, YouTube Music, SoundCloud, SberZvuk.\n\n"
            "Please note that the link preview must load before sending the message; otherwise, the plugin will not work.\n"
            "On some platforms, it is impossible to explicitly separate the track title from the artist name in the preview, so title from preview is used.\n"
        ),
    },
    "ru": {
        "settings_header": "Внешний вид",
        "force_small_preview": "Маленькое превью",
        "force_small_preview_sub": "Всегда использовать компактное превью для музыкальных ссылок",
        "swap_order_title": "Поменять трек и автора местами",
        "swap_order_sub": "Отображать 'Исполнитель • Название' вместо 'Название • Исполнитель'",
        "separator_title": "Разделитель",
        "separator_sub": "Символ между названием трека и исполнителем",
        "separator_divider_hint": "Разделитель между названием трека и исполнителем.\nПример: Название {sep} Исполнитель",
        "separator_divider_hint_swapped": "Разделитель между названием трека и исполнителем.\nПример: Исполнитель {sep} Название",
        "additional_header": "Дополнительно",
        "apply_to_all_links_title": "Применять ко всем ссылкам (экспериментально)",
        "apply_to_all_links_sub": "Использовать заголовок страницы в качестве гиперссылки для любых ссылок",
        "exceptions_hint": "Пример: google.com, youtu.be, t.me",
        "exceptions_divider_hint": "Ссылки-исключения без https://, каждое новое исключение через запятую.",
        "info_header": "Информация",
        "about_title": "Возможности и ограничения",
        "about_dialog_message": (
            "Плагин преобразует ссылки с музыкальных сервисов в гиперссылку с названием и автором.\n\n"
            "Поддерживаемые платформы: Spotify, Apple Music, Яндекс Музыка, Tidal, YouTube Music, SoundCloud, СберЗвук.\n\n"
            "Имейте ввиду, что требуется загрузка превью ссылки перед отправкой сообщения, иначе плагин не сработает.\n"
            "У некоторых платформ невозможно явно отделить название трека от имени автора в превью, поэтому используется тайтл с превью.\n"
        ),
    },
}

def get_string(key: str, **kwargs: Any) -> str:
    try:
        lang = Locale.getDefault().getLanguage()
    except Exception:
        lang = "en"
    lang_dict = STRINGS.get(lang, STRINGS["en"])
    text = lang_dict.get(key, STRINGS["en"].get(key, key))
    if kwargs:
        try:
            return text.format(**kwargs)
        except Exception:
            return text
    return text

def get_webpage_from_params(params: Any) -> TLRPC.WebPage | None:
    webpage = getattr(params, "webPage", None)
    if not webpage and getattr(params, "mediaWebPage", None):
        webpage = getattr(params.mediaWebPage, "webpage", None)
    return webpage

def is_webpage_loaded(webpage: Any) -> bool:
    return bool(webpage) and not isinstance(webpage, (TLRPC.TL_webPagePending, TLRPC.TL_webPageEmpty))

def get_webpage_media_from_params(params: Any) -> TLRPC.TL_messageMediaWebPage | None:
    media = getattr(params, "mediaWebPage", None)
    if isinstance(media, TLRPC.TL_messageMediaWebPage):
        return media
    return None

def get_force_small_media(params: Any) -> bool:
    media = get_webpage_media_from_params(params)
    return bool(getattr(media, "force_small_media", False)) if media else False

def set_force_small_media(params: Any, small: bool = True) -> TLRPC.TL_messageMediaWebPage | None:
    media = getattr(params, "mediaWebPage", None)
    if not media and getattr(params, "webPage", None):
        media = TLRPC.TL_messageMediaWebPage()
        media.webpage = params.webPage
        params.mediaWebPage = media

    if media:
        media.force_small_media = small
        media.force_large_media = not small

    return media

def to_list(java_or_py_list: Any) -> list:
    if java_or_py_list is None:
        return []
    if hasattr(java_or_py_list, "size") and callable(java_or_py_list.size):
        try:
            count = int(java_or_py_list.size())
            return [java_or_py_list.get(i) for i in range(count)]
        except Exception:
            pass
    try:
        return list(java_or_py_list)
    except Exception:
        return []

def get_utf16_length(text: str) -> int:
    return len(text.encode("utf-16-le")) // 2

def normalize_link(url: str) -> str:
    url = url.strip()
    url = re.sub(r'^https?://', '', url, flags=re.IGNORECASE)
    url = re.sub(r'^www\.', '', url, flags=re.IGNORECASE)
    url = url.rstrip('/')
    return url.lower()

def is_url_excluded(url: str, exceptions_str: str) -> bool:
    if not exceptions_str or not exceptions_str.strip():
        return False
    raw_items = re.split(r'[,;\n]+', exceptions_str)
    target = normalize_link(url)
    for raw_item in raw_items:
        exc = normalize_link(raw_item)
        if not exc:
            continue
        if target == exc or target.startswith(exc + '/') or target.startswith(exc + '?') or target.startswith(exc + '#'):
            return True
    return False

class SongLinkerPlugin(BasePlugin):
    def on_plugin_load(self):
        self.add_on_send_message_hook()
        self.log("songlinker plugin loaded")

    def on_send_message_hook(self, account: int, params: Any) -> HookResult:
        if not isinstance(getattr(params, "message", None), str):
            return HookResult()
        
        message = params.message
        apply_to_all = bool(self.get_setting("apply_to_all_links", False))

        matched = self.find_matched_url(message)
        if not matched and apply_to_all:
            matched = self.find_generic_url(message)

        if not matched:
            return HookResult()

        full_url, platform, start, end = matched

        raw_exceptions = str(self.get_setting("exceptions", ""))
        if apply_to_all and is_url_excluded(full_url, raw_exceptions):
            self.log(f"URL {full_url} is in exceptions, skipping")
            return HookResult()

        webpage_metadata = self.extract_metadata(params)
        if not webpage_metadata:
            return HookResult()

        url_title, url_desc = webpage_metadata

        webpage = get_webpage_from_params(params)
        webpage_url = getattr(webpage, "url", None) if webpage else None
        target_url = str(webpage_url) if webpage_url else full_url

        if apply_to_all and is_url_excluded(target_url, raw_exceptions):
            self.log(f"Resolved URL {target_url} is in exceptions, skipping")
            return HookResult()

        if platform:
            metadata = self.parse_info(platform, url_title, url_desc)
            raw_separator = self.get_setting("separator", SEPARATOR)
            separator = raw_separator if raw_separator else SEPARATOR
            swap_order = bool(self.get_setting("swap_order", False))

            if isinstance(metadata, tuple):
                title, authors = metadata
                label = self.format_label(separator, title, authors, swap_order=swap_order)
            else:
                label = str(metadata)
        else:
            clean_title = url_title.strip() if url_title else ""
            label = clean_title if clean_title else full_url

        if self.apply_hyperlink(params, label, target_url, start, end):
            self.log(f"Formatted message with hyperlink: {label} -> {target_url}")
            if bool(self.get_setting("small_link_preview", False)):
                self.apply_force_small_media(params)
                self.log("Applied force small media preview")
            return HookResult(strategy=HookStrategy.MODIFY, params=params)

        return HookResult()

    def extract_metadata(self, params: Any) -> tuple[str, str] | None:
        webpage = get_webpage_from_params(params)

        if not is_webpage_loaded(webpage):
            self.log("webpage preview is not loaded yet or empty")
            return None

        raw_title = getattr(webpage, "title", None) or getattr(webpage, "site_name", None)
        if not raw_title:
            self.log("webpage has no title or site_name")
            return None

        raw_desc = getattr(webpage, "description", None) or ""

        return str(raw_title), str(raw_desc)

    def apply_hyperlink(self, params: Any, label: str, raw_url: str, start: int, end: int) -> bool:
        if not raw_url.startswith(("http://", "https://")):
            raw_url = f"https://{raw_url}"

        message = getattr(params, "message", None)
        if not isinstance(message, str) or start < 0 or end > len(message) or start >= end:
            return False

        try:
            link_offset = get_utf16_length(message[:start])
            old_len = get_utf16_length(message[start:end])
            new_len = get_utf16_length(label)
            diff = new_len - old_len

            params.message = message[:start] + label + message[end:]

            url_entity = TLRPC.TL_messageEntityTextUrl()
            url_entity.offset = int(link_offset)
            url_entity.length = int(new_len)
            url_entity.url = str(raw_url)

            entities_list: List[Any] = []
            raw_entities = getattr(params, "entities", None)
            for entity in to_list(raw_entities):
                ent_offset = int(getattr(entity, "offset", 0))
                ent_length = int(getattr(entity, "length", 0))
                ent_end = ent_offset + ent_length

                is_url = isinstance(entity, (TLRPC.TL_messageEntityUrl, TLRPC.TL_messageEntityTextUrl)) or (
                    "messageEntityUrl" in type(entity).__name__ or "messageEntityTextUrl" in type(entity).__name__
                )
                if ent_offset == link_offset and ent_length == old_len and is_url:
                    continue

                if ent_end <= link_offset:
                    entities_list.append(entity)
                elif ent_offset >= link_offset + old_len:
                    entity.offset = int(ent_offset + diff)
                    entities_list.append(entity)
                elif ent_offset <= link_offset and ent_end >= link_offset + old_len:
                    entity.length = int(ent_length + diff)
                    entities_list.append(entity)
                else:
                    if ent_offset < link_offset:
                        entity.length = int(max(0, link_offset - ent_offset))
                        entities_list.append(entity)
                    elif ent_offset < link_offset + old_len:
                        overlap = (link_offset + old_len) - ent_offset
                        entity.offset = int(link_offset + new_len)
                        entity.length = int(max(0, ent_length - overlap))
                        if entity.length > 0:
                            entities_list.append(entity)

            entities_list.append(url_entity)
            entities_list.sort(key=lambda e: getattr(e, "offset", 0))

            new_entities = ArrayList()
            for e in entities_list:
                new_entities.add(e)
            params.entities = new_entities

            return True
        except Exception as e:
            self.log(f"Error while formatting hyperlink: {e}")
            return False

    def apply_force_small_media(self, params: Any) -> None:
        webpage = get_webpage_from_params(params)
        if not is_webpage_loaded(webpage):
            return
        res = set_force_small_media(params, small=True)
        if res:
            self.log("Set force_small_media=True on mediaWebPage")

    def find_matched_url(self, message: str) -> tuple[str, str, int, int] | None:
        match = URL_REGEX.search(message)
        if not match:
            return None
        raw_full = match.group("full_url")
        full_url = raw_full.rstrip(".,;:!?)\"'>")
        platform = match.group("platform")
        start = match.start("full_url")
        end = start + len(full_url)
        return full_url, platform, start, end

    def find_generic_url(self, message: str) -> tuple[str, str, int, int] | None:
        match = GENERIC_URL_REGEX.search(message)
        if not match:
            return None
        raw_full = match.group("full_url")
        full_url = raw_full.rstrip(".,;:!?)\"'>")
        start = match.start("full_url")
        end = start + len(full_url)
        return full_url, "", start, end

    def find_matched_prefix(self, url: str) -> str | None:
        match = URL_REGEX.search(url)
        return match.group("platform") if match else None

    def format_label(self, separator: str, title: str, authors: str = "", swap_order: bool = False) -> str:
        if not authors:
            return f"{title}"

        first, second = (authors, title) if swap_order else (title, authors)
        sep = separator.strip()
        if not sep:
            return f"{first} {second}"

        return f"{first} {sep} {second}"

    def parse_info(self, platform: str, url_title: str, url_description: str = ""):
        match platform:
            case "open.spotify.com/track/" | "music.yandex.com/album/" | "music.yandex.ru/album/":
                if platform == "open.spotify.com/track/":
                    sections = url_description.split("·") 
                else:
                    sections = url_description.split("•")

                title = url_title
                authors = sections[0].strip()

                return title, authors

            case "open.spotify.com/album/": 
                sections_title = re.split(r'\s+[-–—−]\s+', url_title)
                # "Back-off! - Single by junoes | Spotify" 
                # -> ['Back-off!', 'Single by junoes | Spotify']

                title = sections_title[0]

                sections_desc = url_description.split("·")
                authors = sections_desc[0].strip()

                return title, authors

            case "music.apple.com/ru/album/" | "music.apple.com/ru/song/":
                # "Альбом «Album Name - Single» (Author) в Apple Music"
                # "Песня «Track Name» (Author 1, Author 2, ...) в Apple Music"

                title_matches = re.findall(r'«([^»]*)»', url_title)           # extract all from '«»'
                authors_matches = re.findall(r'»\s*\(([^)]*)\)', url_title)   # extract all from '()'

                sections = re.split(r'\s+[-–—−]\s+', " ".join(title_matches)) 

                if len(sections) > 1:
                    cleared_title = re.split(r'\s+[-–—−]\s+', " ".join(title_matches))   
                    title = cleared_title[0]  # remove 'Single' or 'EP'
                else:
                    title = " ".join(title_matches)
                    
                authors = " ".join(authors_matches)

                return title, authors

            case "music.apple.com/us/album/" | "music.apple.com/us/song/":
                # "Album Name - Single by Artist on Apple Music"
                # or "Album Name by Artist on Apple Music"
                # "Track Name by Artist 1, Artist 2 on Apple Music"

                clean_title = re.sub(r'\s+on\s+Apple\s+Music$', '', url_title, flags=re.IGNORECASE).strip()

                by_matches = re.findall(r'\s+by\s+', clean_title, flags=re.IGNORECASE)
                if len(by_matches) == 1:
                    raw_title, raw_author = re.split(r'\s+by\s+', clean_title, maxsplit=1, flags=re.IGNORECASE)
                    title = re.sub(r'\s+[-–—−]\s+(?:Single|EP)$', '', raw_title, flags=re.IGNORECASE).strip()
                    author = raw_author.strip()
                    return title, author

                return clean_title

            case "music.apple.com/us/playlist/" | "music.apple.com/ru/playlist/":
                # ru: Плейлист «Playlist Name» в Apple Music
                # en: PLaylist Name on Apple Music
                match = re.findall(r'«([^»]*)»', url_title) 

                if match:
                    title = " ".join(match)
                else:
                    title = re.sub(r'\s+on\s+Apple\s+Music$', '', url_title, flags=re.IGNORECASE).strip()

                return title

            case "tidal.com/":
                sections = re.split(r'\s+[-–—−]\s+', url_title) 

                if len(sections) > 1:
                    title = sections[1]
                    authors = sections[0]

                    return title, authors
                else:
                    return url_title
                
            case "zvuk.com/":
                sections = re.split(r'\s+[-–—−]\s+', url_title) 
                if len(sections) > 1:
                    title = sections[0]
                    return title
                else:
                    return url_title
    
            case _:
                return url_title # fallback

    def show_about_dialog(self, *args: Any, **kwargs: Any) -> None:
        self.log("Opening about dialog")
        view = args[0] if args else None
        context = None
        if view and hasattr(view, "getContext"):
            context = view.getContext()
        if not context:
            fragment = get_last_fragment()
            if fragment:
                context = fragment.getParentActivity() or fragment.getContext()
        if not context:
            self.log("Failed to obtain context for dialog")
            return

        try:
            builder = AlertDialogBuilder(context)
            builder.set_title("Song Linker")
            builder.set_message(get_string("about_dialog_message"))
            builder.set_positive_button(
                "OK",
                lambda *b_args: b_args[0].dismiss() if b_args else None,
            )
            builder.show()
        except Exception as e:
            self.log(f"Error displaying about dialog: {e}")

    def _on_apply_to_all_change(self, *args: Any) -> None:
        value = bool(args[0]) if args else False
        self.log(f"apply_to_all_links changed to {value}")
        self.set_setting("apply_to_all_links", value, reload_settings=True)

    def create_settings(self) -> List[Any]:
        current_sep = self.get_setting("separator", SEPARATOR)
        sep_display = current_sep.strip() if current_sep and current_sep.strip() else SEPARATOR
        swap_order = bool(self.get_setting("swap_order", False))
        apply_to_all_links = bool(self.get_setting("apply_to_all_links", False))

        divider_key = "separator_divider_hint_swapped" if swap_order else "separator_divider_hint"

        items = [
            Header(text=get_string("settings_header")),
            Switch(
                key="small_link_preview",
                text=get_string("force_small_preview"),
                default=self.get_setting("small_link_preview", False),
                subtext=get_string("force_small_preview_sub"),
                icon="msg_link2",
            ),
            Switch(
                key="swap_order",
                text=get_string("swap_order_title"),
                default=swap_order,
                subtext=get_string("swap_order_sub"),
                icon="msg_forward_replace",
            ),
            Input(
                key="separator",
                text=get_string("separator_title"),
                default=self.get_setting("separator", SEPARATOR),
                subtext=get_string("separator_sub"),
                icon="msg_edit",
            ),
            Divider(
                text=get_string(divider_key, sep=sep_display),
            ),
            Header(text=get_string("additional_header")),
            Switch(
                key="apply_to_all_links",
                text=get_string("apply_to_all_links_title"),
                default=apply_to_all_links,
                subtext=get_string("apply_to_all_links_sub"),
                icon="msg_link",
                on_change=self._on_apply_to_all_change,
            ),
        ]

        if apply_to_all_links:
            items.extend([
                EditText(
                    key="exceptions",
                    hint=get_string("exceptions_hint"),
                    default=self.get_setting("exceptions", ""),
                    multiline=True,
                    max_length=2000,
                ),
                Divider(
                    text=get_string("exceptions_divider_hint"),
                ),
            ])
        else:
            items.append(Divider())

        items.extend([
            Header(text=get_string("info_header")),
            Text(
                text=get_string("about_title"),
                icon="msg_info",
                accent=True,
                on_click=self.show_about_dialog,
            ),
        ])

        return items