#!/usr/bin/env python3

import math
import re
import sys
from collections import Counter

import source_discovery_worker as sd


MIN_FEEDBACK = 10
FULL_CONFIDENCE_AT = 80
MAX_ADJUSTMENT = 20.0


STOPWORDS = {
    # Persian generic
    "و", "در", "به", "از", "که", "این", "آن", "برای", "با",
    "یک", "را", "رو", "های", "ها", "می", "است", "هست",
    "کانال", "تلگرام", "telegram", "channel", "official",

    # Generic social words that usually add little signal
    "join", "link", "admin", "contact", "پیج", "صفحه",
    "ادمین", "تبلیغات", "تبلیغ",

    # Internal / classifier-like generic words
    "deep", "positive", "compatible",
    "mismatch", "max_mismatch", "unknown",
    "passed", "failed"
}


def normalize_text(value):
    value = str(value or "").lower()

    value = value.replace("ي", "ی")
    value = value.replace("ك", "ک")

    value = re.sub(
        r"https?://\S+",
        " ",
        value
    )

    value = re.sub(
        r"t\.me/\S+",
        " ",
        value
    )

    value = re.sub(
        r"@[a-zA-Z0-9_]+",
        lambda m: " " + m.group(0)[1:] + " ",
        value
    )

    value = re.sub(
        r"[^\w\u0600-\u06FF]+",
        " ",
        value,
        flags=re.UNICODE
    )

    return re.sub(
        r"\s+",
        " ",
        value
    ).strip()


def tokenize(value):
    text = normalize_text(value)

    tokens = []

    for token in text.split():

        token = token.strip("_")

        if not token:
            continue

        if len(token) < 3:
            continue

        if token in STOPWORDS:
            continue

        if token.isdigit():
            continue

        tokens.append(token)

    return tokens


def feedback_text(row):
    """
    Human feedback learning must learn from the actual
    Telegram channel identity/content metadata.

    IMPORTANT:
    Do NOT include quality_reason here.
    quality_reason is generated by our own classifier and
    would create feedback leakage such as:
    compatible / positive / deep / max_mismatch.
    """
    return " ".join([
        str(row.get("username") or ""),
        str(row.get("name") or ""),
        str(row.get("description") or ""),
    ])


def candidate_text(row):
    """
    Candidate comparison uses only Telegram-side metadata.

    quality_reason is intentionally excluded because it is
    machine-generated and must not influence human-feedback
    similarity learning.
    """
    return " ".join([
        str(row.get("username") or ""),
        str(row.get("name") or ""),
        str(row.get("description") or ""),
    ])


def load_feedback(db, group_id):
    with db.cursor() as cursor:

        cursor.execute(
            """
            SELECT
                id,
                candidate_id,
                username,
                name,
                description,
                decision,
                relevance_score,
                group_relevance_score,
                quality_status,
                quality_reason
            FROM source_discovery_feedback
            WHERE group_id = %s
              AND decision IN (
                    'approved',
                    'rejected'
                  )
            ORDER BY id ASC
            """,
            (group_id,)
        )

        return cursor.fetchall()


def load_candidates(db, group_id):
    with db.cursor() as cursor:

        cursor.execute(
            """
            SELECT
                id,
                username,
                name,
                description,
                relevance_score,
                group_relevance_score,
                quality_status,
                quality_reason,
                review_status
            FROM source_discovery_candidates
            WHERE group_id = %s
              AND review_status = 'pending'
            ORDER BY id ASC
            """,
            (group_id,)
        )

        return cursor.fetchall()


def build_profile(feedback):
    approved_docs = []
    rejected_docs = []

    for row in feedback:

        tokens = set(
            tokenize(
                feedback_text(row)
            )
        )

        if not tokens:
            continue

        if row["decision"] == "approved":
            approved_docs.append(tokens)

        elif row["decision"] == "rejected":
            rejected_docs.append(tokens)


    approved_df = Counter()
    rejected_df = Counter()

    for tokens in approved_docs:
        approved_df.update(tokens)

    for tokens in rejected_docs:
        rejected_df.update(tokens)


    approved_total = max(
        1,
        len(approved_docs)
    )

    rejected_total = max(
        1,
        len(rejected_docs)
    )


    token_weights = {}

    all_tokens = (
        set(approved_df.keys())
        | set(rejected_df.keys())
    )

    for token in all_tokens:

        approve_rate = (
            approved_df[token]
            / approved_total
        )

        reject_rate = (
            rejected_df[token]
            / rejected_total
        )

        # Positive = resembles approved material
        # Negative = resembles rejected material
        weight = approve_rate - reject_rate

        if abs(weight) < 0.03:
            continue

        token_weights[token] = weight


    return {
        "approved_docs": len(approved_docs),
        "rejected_docs": len(rejected_docs),
        "weights": token_weights,
    }


def score_candidate(candidate, profile, total_feedback):
    tokens = set(
        tokenize(
            candidate_text(candidate)
        )
    )

    positive_matches = []
    negative_matches = []

    positive_sum = 0.0
    negative_sum = 0.0

    weights = profile["weights"]


    for token in tokens:

        weight = weights.get(
            token,
            0.0
        )

        if weight > 0:
            positive_sum += weight
            positive_matches.append(
                (token, weight)
            )

        elif weight < 0:
            negative_sum += abs(weight)
            negative_matches.append(
                (token, abs(weight))
            )


    total_signal = (
        positive_sum
        + negative_sum
    )


    if total_signal <= 0:
        raw_learning = 0.0

    else:
        raw_learning = (
            positive_sum
            - negative_sum
        ) / total_signal


    confidence = min(
        1.0,
        total_feedback
        / FULL_CONFIDENCE_AT
    )


    adjustment = (
        raw_learning
        * MAX_ADJUSTMENT
        * confidence
    )


    # Conservative at beginning
    adjustment = max(
        -MAX_ADJUSTMENT,
        min(
            MAX_ADJUSTMENT,
            adjustment
        )
    )


    if (
        candidate.get(
            "group_relevance_score"
        )
        is not None
    ):
        base_score = float(
            candidate[
                "group_relevance_score"
            ]
        )
        base_source = "group_relevance_score"

    else:
        base_score = float(
            candidate.get(
                "relevance_score"
            )
            or 0
        )
        base_source = "relevance_score"


    final_score = max(
        0.0,
        min(
            100.0,
            base_score + adjustment
        )
    )


    positive_matches.sort(
        key=lambda x: x[1],
        reverse=True
    )

    negative_matches.sort(
        key=lambda x: x[1],
        reverse=True
    )


    positive_words = [
        item[0]
        for item
        in positive_matches[:5]
    ]

    negative_words = [
        item[0]
        for item
        in negative_matches[:5]
    ]


    reason_parts = [
        f"shadow_learning",
        f"feedback={total_feedback}",
        f"confidence={confidence:.2f}",
        f"base={base_source}:{base_score:.2f}",
    ]


    if positive_words:
        reason_parts.append(
            "approved_like="
            + ",".join(
                positive_words
            )
        )


    if negative_words:
        reason_parts.append(
            "rejected_like="
            + ",".join(
                negative_words
            )
        )


    if (
        not positive_words
        and not negative_words
    ):
        reason_parts.append(
            "no_known_pattern"
        )


    reason = "; ".join(
        reason_parts
    )


    return (
        round(adjustment, 2),
        round(final_score, 2),
        reason[:500]
    )


def save_learning(
    db,
    candidate_id,
    adjustment,
    final_score,
    reason
):
    with db.cursor() as cursor:

        cursor.execute(
            """
            UPDATE source_discovery_candidates
            SET
                learning_adjustment = %s,
                final_score = %s,
                learning_reason = %s,
                learning_checked_at =
                    UTC_TIMESTAMP()
            WHERE id = %s
            LIMIT 1
            """,
            (
                adjustment,
                final_score,
                reason,
                candidate_id
            )
        )

    db.commit()


def main():
    group_key = (
        str(
            sys.argv[1]
        ).strip().lower()
        if len(sys.argv) >= 2
        else "deep"
    )


    db = sd.get_database()

    try:

        group = sd.get_group(
            db,
            group_key
        )

        if not group:
            raise RuntimeError(
                f"Group '{group_key}' not found."
            )


        group_id = int(
            group["id"]
        )


        feedback = load_feedback(
            db,
            group_id
        )


        total_feedback = len(
            feedback
        )

        approved_count = sum(
            1
            for row in feedback
            if row["decision"]
            == "approved"
        )

        rejected_count = sum(
            1
            for row in feedback
            if row["decision"]
            == "rejected"
        )


        print("")
        print(
            "SOURCE DISCOVERY LEARNING V1"
        )
        print(
            "=============================================="
        )
        print(
            f"Group:          {group_key}"
        )
        print(
            f"Feedback:       {total_feedback}"
        )
        print(
            f"Approved:       {approved_count}"
        )
        print(
            f"Rejected:       {rejected_count}"
        )
        print(
            "Mode:           SHADOW"
        )
        print(
            "=============================================="
        )


        if total_feedback < MIN_FEEDBACK:

            print(
                "Not enough feedback yet."
            )
            print(
                f"Need at least:   {MIN_FEEDBACK}"
            )
            return


        profile = build_profile(
            feedback
        )


        print(
            "Profile docs:   "
            f"{profile['approved_docs']} approved / "
            f"{profile['rejected_docs']} rejected"
        )

        print(
            f"Learned tokens: {len(profile['weights'])}"
        )


        candidates = load_candidates(
            db,
            group_id
        )


        print(
            f"Candidates:     {len(candidates)}"
        )
        print(
            "----------------------------------------------"
        )


        updated = 0


        for candidate in candidates:

            adjustment, final_score, reason = (
                score_candidate(
                    candidate,
                    profile,
                    total_feedback
                )
            )


            save_learning(
                db,
                int(candidate["id"]),
                adjustment,
                final_score,
                reason
            )


            updated += 1


        print(
            f"Updated:        {updated}"
        )
        print(
            "=============================================="
        )
        print(
            "LEARNING SHADOW COMPLETE ✅"
        )
        print(
            "No discovery/filter behavior was changed."
        )
        print(
            "=============================================="
        )


    finally:
        db.close()


if __name__ == "__main__":
    main()
