"""
Lyrics Moderation System v3

This module provides functionality for detecting potential copyright infringement in song lyrics
using Elasticsearch-based similarity search. It supports multiple languages and implements
a tiered protection system based on song popularity (view count).

The system uses different matching thresholds for viral songs (>10M views) vs regular songs,
and supports specialized text analysis for various languages including CJK.
"""

import logging
import time
from datadog import statsd

try:
    from elasticsearch import Elasticsearch
    from langdetect import detect
except ImportError:
    print("Elasticsearch or langdetect not installed.")
    Elasticsearch = None
    detect = None

NON_WHITESPACE_LANGS = frozenset({"zh", "ja", "th", "km", "lo", "my"})

DEFAULT_INDEX_NAME = "prod_lyrics_data"
STRICT_INDEX_NAME = "prod_lyrics_data_v2"
# Default ES client


# How many views a song needs to be protected by this system
MIN_VIEWS_FOR_PROTECTION = 1000000

# Songs with this many views are considered "viral" and get stricter protection
VIRAL_SONG_VIEW_THRESHOLD = 10000000

# For regular songs, what percentage of phrases must match to trigger detection
# Higher = stricter matching, fewer false positives
STANDARD_PHRASE_MATCH_THRESHOLD = "50%"

# For viral songs, use a lower threshold to catch partial copying
# Lower = catches more potential copies, more false positives
VIRAL_PHRASE_MATCH_THRESHOLD = "30%"

# Add a minimum required matches constant
MIN_VIRAL_MATCHES = 3

# Define supported languages with their corresponding field suffixes
SUPPORTED_LANGUAGES = {
    # CJK languages use cjk analyzer
    "ja": "text.cjk",
    "zh": "text.cjk",
    "ko": "text.cjk",
    # Default English/Latin script languages use base analyzer
    "en": "text.phrase",
    "es": "text.phrase",
    "fr": "text.phrase",
    # Add more supported languages as analyzers are added
}

# Common invisible/zero-width characters
INVISIBLE_CHARS = [
    "\u200e",  # Left-to-Right Mark
    "\u200f",  # Right-to-Left Mark
    "\u200b",  # Zero Width Space
    "\u200c",  # Zero Width Non-Joiner
    "\u200d",  # Zero Width Joiner
    "\u2060",  # Word Joiner
    "\u2061",  # Function Application
    "\u2062",  # Invisible Times
    "\u2063",  # Invisible Separator
    "\u2064",  # Invisible Plus
    "\ufeff",  # Zero Width No-Break Space
]

logger = logging.getLogger(__name__)


def _get_field_for_text(text: str) -> str:
    """Determine which text field to use based on language detection."""
    try:
        lang = detect(text)
    except:  # noqa: E722
        return "text"  # Default to standard analyzer if detection fails

    # Use specialized field if language is supported, otherwise fall back to standard
    return SUPPORTED_LANGUAGES.get(lang, "text.phrase")


# We could also do this in Elasticsearch but, to better capture when users actually
# add illegal characters, I opted for a pre-processing step instead.
def remove_invisible_chars(text: str) -> str:
    """Remove all invisible/zero-width characters and any spaces that follow them."""
    result = text
    for char in INVISIBLE_CHARS:
        # Remove invisible char + space combination
        result = result.replace(char + " ", "")
        # Remove just the invisible char
        result = result.replace(char, "")
    return result


def remove_repeated_noise_phrases(text: str, phrase_len: int = 5, max_word_len: int = 3) -> str:
    """
    Remove repeated noise phrases like 'la la la la la' from the text.
    Only removes phrases where all words are <= max_word_len and identical.
    """
    words = text.split()
    cleaned_words = []
    i = 0
    while i <= len(words) - phrase_len:
        phrase = words[i : i + phrase_len]
        if all(len(w) <= max_word_len for w in phrase) and len(set(phrase)) == 1:
            # Skip this phrase
            i += phrase_len
            continue
        cleaned_words.append(words[i])
        i += 1
    # Add any trailing words that weren't part of a phrase
    cleaned_words.extend(words[i:])
    return " ".join(cleaned_words)


class CopyrightDetector:
    def __init__(self, lyrics_mod_es_url, lyrics_mod_es_key, deployment_type):
        self.es_client = Elasticsearch(lyrics_mod_es_url, api_key=lyrics_mod_es_key)
        self.deployment_type = deployment_type

    def are_lyrics_copyrighted(self, lyrics: str, strict: bool = False) -> bool:
        print("Running copyright detector with strict mode?", strict)
        result = self._search_lyrics_v3(lyrics, threshold=0.9, timeout_ms=600, strict=strict)
        return result["is_copyright_infringement"]

    def _search_lyrics_v3(
        self, lyrics: str, threshold: float, timeout_ms: int = 600, strict: bool = False
    ) -> dict:
        """Optimized lyrics search for phrase detection."""
        start_time = time.time()
        timed_out = False

        # Clean lyrics before processing
        has_invisible = False
        try:
            # Certain unicode edgecases can throw here but it should be extremely rare
            # and shouldn't impact the rest of the copyright check
            len_before = len(lyrics)
            lyrics = remove_invisible_chars(lyrics)
            has_invisible = len(lyrics) < len_before
        except Exception as e:
            logger.warning(f"Could not remove invisible characters: {e}")

        has_noise = False
        try:
            len_before = len(lyrics)
            lyrics = remove_repeated_noise_phrases(lyrics)
            has_noise = len(lyrics) < len_before
        except Exception as e:
            logger.warning(f"Could not remove noise phrases: {e}")

        # Get detected language for metrics
        try:
            detected_lang = detect(lyrics)
        except:  # noqa: E722
            detected_lang = "unknown"

        field = _get_field_for_text(lyrics)

        # Scale threshold from 0-1 range to 0-500 range used by scoring
        scaled_min_score = threshold * 500
        boost_multiplier = 2 if len(lyrics.split()) < 50 else 1

        should_clauses = [
            # Add a high-boost exact phrase matcher using the appropriate field
            {
                "match_phrase": {
                    field: {
                        "query": lyrics,
                        "boost": 25.0 * boost_multiplier,
                        "_name": "exact_phrase_match",
                    }
                }
            },
            # For viral songs (>10M views), use appropriate field
            {
                "bool": {
                    "must": [
                        {"range": {"views": {"gte": VIRAL_SONG_VIEW_THRESHOLD}}},
                        {
                            "match": {
                                field: {
                                    "query": lyrics,
                                    # Require both percentage and minimum matches
                                    "minimum_should_match": f"{MIN_VIRAL_MATCHES}<{VIRAL_PHRASE_MATCH_THRESHOLD}",
                                    "boost": 20.0 * boost_multiplier,
                                    "_name": "viral_song_coverage",
                                }
                            }
                        },
                    ],
                    "_name": "viral_song_match",
                }
            },
            # For standard songs, use appropriate field
            {
                "bool": {
                    "must": [
                        {
                            "range": {
                                "views": {
                                    "gte": MIN_VIEWS_FOR_PROTECTION,
                                    "lt": VIRAL_SONG_VIEW_THRESHOLD,
                                }
                            }
                        },
                        {
                            "match": {
                                field: {
                                    "query": lyrics,
                                    "minimum_should_match": f"6<{STANDARD_PHRASE_MATCH_THRESHOLD}",
                                    "boost": 15.0 * boost_multiplier,
                                    "_name": "regular_song_sliding_window",
                                }
                            }
                        },
                    ],
                    "_name": "regular_song_match",
                }
            },
        ]

        if strict:
            print("Adding match_phrase clauses for unique 5-word phrases")
            phrases = [" ".join(lyrics.split()[i : i + 5]) for i in range(len(lyrics.split()) - 4)]
            should_clauses += [{"match_phrase": {"text.phrase": phrase}} for phrase in set(phrases)]

        query = {
            "_source": ["text", "views", "lang"],
            "size": 1,
            "min_score": scaled_min_score,
            "explain": True,
            "query": {
                "bool": {
                    "should": should_clauses,
                    "minimum_should_match": 1,
                },
            },
        }

        index = STRICT_INDEX_NAME

        try:
            results = self.es_client.search(
                index=index,
                body=query,
                _source=True,
                stored_fields=["text.phrase"],
                timeout=f"{timeout_ms}ms",
            )
            # Check if the query timed out
            timed_out = results.get("timed_out", False)
            if timed_out:
                logger.warning(
                    f"Lyrics search timed out after {timeout_ms}ms for language: {detected_lang}"
                )
        except Exception as e:
            logger.error(f"Error in lyrics search: {e}")
            timed_out = True
            results = {"hits": {"hits": [], "max_score": 0}}

        hits = results["hits"]["hits"]

        def _extract_matched_tokens(explanation):
            """Extract just the shingle tokens from the explanation details."""
            tokens = set()
            if isinstance(explanation, dict):
                desc = explanation.get("description", "")
                if "weight(text.phrase:" in desc:
                    # Extract token between 'text.phrase:' and ' in'
                    token = desc.split("text.phrase:")[1].split(" in")[0]
                    if len(token.split()) >= 4 if strict else 6:  # Only include 4-6-word shingles
                        tokens.add(token)
                for detail in explanation.get("details", []):
                    tokens.update(_extract_matched_tokens(detail))
            return tokens

        def _is_valid_shingle(shingle: str) -> bool:
            """Check if a shingle is valid."""
            words = shingle.split()

            # Return False if all words are short characters
            if all(len(word) <= 3 for word in words):
                return False

            # Check for hyphenated repetitions (e.g. "yeah-yeah-yeah") that may
            # reach the phrase match threshold but are clearly not copyright infringement
            if all(word.count("-") > 0 for word in words):
                parts = [part for word in words for part in word.split("-")]
                unique_parts = set(parts)
                if len(unique_parts) == 1:
                    return False

            # Check for minimum unique words (handles both normal and hyphenated cases)
            unique_words = set(words)
            if len(unique_words) < 4:
                return False

            return True

        similarities = [
            {
                "lyrics_id": hit["_id"],
                "lyrics": hit["_source"]["text"],
                "similarity_score": float(hit["_score"]),
                "normalized_score": 100 * (float(hit["_score"]) / results["hits"]["max_score"])
                if results["hits"]["max_score"]
                else 0,
                "matched_tokens": list(_extract_matched_tokens(hit.get("_explanation", {}))),
                "matched_queries": hit.get("matched_queries", []),
            }
            for hit in hits
            # Only include matches that have at least one valid shingle
            if any(
                _is_valid_shingle(shingle)
                for shingle in _extract_matched_tokens(hit.get("_explanation", {}))
            )
        ]

        # If all matches were filtered out, return empty results
        query_time = round(time.time() - start_time, 3)

        # default
        is_copyright_infringement = False

        if not similarities:
            result = {
                "model_name": "elasticsearch-v1",
                "similarities": [],
                "metadata": {
                    "score": 0.0,
                    "normalized_score": 0.0,
                    "query_time_seconds": query_time,
                    "matched_queries": [],
                    "timed_out": timed_out,
                },
                "is_copyright_infringement": False,
            }
        else:
            # Adjust thresholds for better detection
            is_copyright_infringement = any(
                (
                    # Strong exact match
                    s["similarity_score"] > 500
                    or
                    # High normalized score indicates a good match
                    s["normalized_score"] > 80
                )
                for s in similarities
            )

            # If timed out, force non-copyright infringement response
            if timed_out:
                result = {
                    "model_name": "elasticsearch-v1",
                    "similarities": [],
                    "metadata": {
                        "score": 0.0,
                        "normalized_score": 0.0,
                        "query_time_seconds": query_time,
                        "matched_queries": [],
                        "timed_out": True,
                    },
                    "is_copyright_infringement": False,
                }
            else:
                result = {
                    "model_name": "elasticsearch-v1",
                    "similarities": similarities,
                    "metadata": {
                        "score": similarities[0]["similarity_score"] if similarities else 0.0,
                        "normalized_score": similarities[0]["normalized_score"] if similarities else 0.0,
                        "query_time_seconds": query_time,
                        "matched_queries": similarities[0].get("matched_queries", [])
                        if similarities
                        else [],
                        "timed_out": False,
                    },
                    "is_copyright_infringement": is_copyright_infringement,
                }

        # Track metrics with language and environment tags
        tags = [
            f"env:{self.deployment_type}",
            f"lang:{detected_lang}",
            f"has_invisible:{has_invisible}",
            f"has_noise:{has_noise}",
            f"is_infringement:{is_copyright_infringement}",
            f"timed_out:{timed_out}",
        ]

        # Track throughput and results with all tags
        statsd.increment("copyright_detector.requests", tags=tags)
        if timed_out:  # Only track timeouts when they occur
            statsd.increment("copyright_detector.timeouts", tags=tags)

        statsd.distribution(
            "copyright_detector.query_time",
            query_time * 1000,  # Convert to milliseconds
            tags=tags,
        )

        if similarities:  # Only track similarity score if we have matches
            statsd.distribution(
                "copyright_detector.similarity_score", similarities[0]["similarity_score"], tags=tags
            )

        return result
