"""Match extracted briefing rows to WordPress tours and tour variants.

This produces an auditable report. It does not mutate WordPress or the source
spreadsheet. Accepted mappings are intentionally conservative; ambiguous rows
remain in the review section instead of being guessed.
"""

from __future__ import annotations

import argparse
import difflib
import json
import re
import unicodedata
from pathlib import Path


STOP_WORDS = {
    "and",
    "by",
    "from",
    "in",
    "of",
    "on",
    "the",
    "tour",
    "thailand",
    "pattaya",
    "phuket",
    "в",
    "из",
    "и",
    "на",
    "пхукет",
    "пхукете",
    "паттайя",
    "паттайе",
    "таиланд",
    "таиланда",
    "тур",
    "экскурсия",
    "экскурсии",
}

TOKEN_ALIASES = {
    "one": "1",
    "two": "2",
    "three": "3",
    "four": "4",
    "five": "5",
    "seven": "7",
    "один": "1",
    "два": "2",
    "три": "3",
    "четыре": "4",
    "пять": "5",
    "семь": "7",
    "все": "весь",
    "всё": "весь",
}

CYRILLIC_TO_LATIN = str.maketrans(
    {
        "а": "a",
        "б": "b",
        "в": "v",
        "г": "g",
        "д": "d",
        "е": "e",
        "ё": "e",
        "ж": "zh",
        "з": "z",
        "и": "i",
        "й": "y",
        "к": "k",
        "л": "l",
        "м": "m",
        "н": "n",
        "о": "o",
        "п": "p",
        "р": "r",
        "с": "s",
        "т": "t",
        "у": "u",
        "ф": "f",
        "х": "h",
        "ц": "c",
        "ч": "ch",
        "ш": "sh",
        "щ": "sch",
        "ъ": "",
        "ы": "y",
        "ь": "",
        "э": "e",
        "ю": "yu",
        "я": "ya",
    }
)

EXCLUDED_TOURS = {
    # "Бангкок Сансет" is not the sea program named "Sunset".
    "pattaya:3733",
    # Lake fishing and sea fishing have different preparation.
    "pattaya:2290",
    # Koh Si Chang is not Koh Chang.
    "pattaya:1321",
    # The combined page contains different 79/89 show variants.
    "pattaya:3536",
    # Only the second Krabi variant has an exact spreadsheet program.
    "phuket:2904",
}

EXCLUDED_VARIANTS = {
    ("pattaya:3733", "program-1"),
    ("pattaya:1321", "program-1"),
    ("pattaya:3536", "program-2"),
}

MANUAL_VARIANTS = {
    ("pattaya:1304", "program-1"): ("pattaya", 37),
    ("pattaya:1330", "program-1"): ("pattaya", 12),
    ("pattaya:1330", "program-2"): ("pattaya", 13),
    ("pattaya:1339", "program-1"): ("pattaya", 40),
    ("pattaya:2610", "program-1"): ("pattaya", 18),
    ("pattaya:2655", "program-1"): ("pattaya", 70),
    ("phuket:2914", "program-1"): ("phuket", 21),
    ("phuket:2914", "program-2"): ("phuket", 22),
    ("phuket:2914", "program-3"): ("phuket", 19),
    ("phuket:2914", "program-4"): ("phuket", 19),
    ("phuket:2966", "program-1"): ("phuket", 8),
    ("phuket:2966", "program-2"): ("phuket", 8),
    ("phuket:2991", "program-1"): ("phuket", 13),
    ("phuket:2991", "program-2"): ("phuket", 14),
    ("phuket:3038", "program-1"): ("phuket", 30),
    ("phuket:3038", "program-2"): ("phuket", 30),
}


def normalize(value: object, transliterate: bool = False) -> str:
    text = unicodedata.normalize("NFKC", str(value or "")).casefold()
    text = text.replace("&", " and ").replace("+", " plus ")
    text = re.sub(r"[«»\"'`´]", " ", text)
    text = re.sub(r"[^a-zа-яё0-9]+", " ", text)
    tokens = [
        TOKEN_ALIASES.get(token, token)
        for token in text.split()
        if token not in STOP_WORDS
    ]
    result = " ".join(tokens)
    return result.translate(CYRILLIC_TO_LATIN) if transliterate else result


def score_pair(source: str, target: str) -> float:
    left = normalize(source)
    right = normalize(target)
    if not left or not right:
        return 0.0
    if left == right:
        return 100.0

    left_tokens = set(left.split())
    right_tokens = set(right.split())
    shortest = min(len(left), len(right))
    longest = max(len(left), len(right))
    scores = [
        100 * difflib.SequenceMatcher(None, left, right).ratio(),
        100
        * difflib.SequenceMatcher(
            None,
            " ".join(sorted(left_tokens)),
            " ".join(sorted(right_tokens)),
        ).ratio(),
    ]
    if shortest >= 5 and (left in right or right in left):
        scores.append(84 + 16 * shortest / longest)

    overlap = len(left_tokens & right_tokens)
    if overlap:
        containment = overlap / min(len(left_tokens), len(right_tokens))
        jaccard = overlap / len(left_tokens | right_tokens)
        if containment == 1 and overlap >= 2:
            scores.append(88 + 12 * jaccard)
        scores.append(72 * containment + 28 * jaccard)

    latin_left = normalize(source, transliterate=True)
    latin_right = normalize(target, transliterate=True)
    if latin_left and latin_right:
        scores.append(
            96 * difflib.SequenceMatcher(None, latin_left, latin_right).ratio()
        )
        if (
            min(len(latin_left), len(latin_right)) >= 6
            and (latin_left in latin_right or latin_right in latin_left)
        ):
            scores.append(
                82
                + 14
                * min(len(latin_left), len(latin_right))
                / max(len(latin_left), len(latin_right))
            )
    return round(max(scores), 2)


def entry_names(entry: dict[str, object]) -> list[str]:
    return [
        str(entry.get("englishName") or ""),
        str(entry.get("russianName") or ""),
    ]


def best_entry(
    city: str,
    target_names: list[str],
    entries: list[dict[str, object]],
) -> tuple[dict[str, object] | None, float, float, str, str]:
    ranked: list[tuple[float, dict[str, object], str, str]] = []
    for entry in entries:
        if entry.get("city") != city:
            continue
        best_score = 0.0
        best_source = ""
        best_target = ""
        for source_name in entry_names(entry):
            for target_name in target_names:
                score = score_pair(source_name, target_name)
                if score > best_score:
                    best_score = score
                    best_source = source_name
                    best_target = target_name
        ranked.append((best_score, entry, best_source, best_target))
    ranked.sort(key=lambda row: row[0], reverse=True)
    if not ranked:
        return None, 0.0, 0.0, "", ""
    best = ranked[0]
    second_score = ranked[1][0] if len(ranked) > 1 else 0.0
    return best[1], best[0], second_score, best[2], best[3]


def mapping_row(
    tour: dict[str, object],
    entry: dict[str, object],
    score: float,
    second_score: float,
    source_name: str,
    target_name: str,
    variant: dict[str, object] | None = None,
) -> dict[str, object]:
    return {
        "tourId": tour["id"],
        "tourTitle": tour["title"],
        "variantId": variant.get("id") if variant else None,
        "variantName": variant.get("name") if variant else None,
        "score": score,
        "margin": round(score - second_score, 2),
        "matchedSourceName": source_name,
        "matchedTargetName": target_name,
        "sourceSheet": entry["sourceSheet"],
        "sourceRow": entry["sourceRow"],
        "englishName": entry["englishName"],
        "russianName": entry["russianName"],
        "whatToTake": entry["whatToTake"],
    }


def is_accepted(row: dict[str, object]) -> bool:
    score = float(row["score"])
    margin = float(row["margin"])
    return score >= 96 or (score >= 90 and margin >= 2.5) or (
        score >= 86 and margin >= 7
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("source", type=Path)
    parser.add_argument("catalog", type=Path)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--data-output", type=Path)
    args = parser.parse_args()

    source = json.loads(args.source.read_text(encoding="utf-8"))
    catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
    entries = source["entries"]
    tours = catalog["data"]

    accepted_tours: list[dict[str, object]] = []
    accepted_variants: list[dict[str, object]] = []
    review: list[dict[str, object]] = []

    for tour in tours:
        city = str(tour["city"])
        tour_targets = [str(tour["title"]), str(tour.get("slug") or "")]
        entry, score, second, source_name, target_name = best_entry(
            city, tour_targets, entries
        )
        if entry:
            row = mapping_row(
                tour,
                entry,
                score,
                second,
                source_name,
                target_name,
            )
            if is_accepted(row):
                accepted_tours.append(row)
            elif score >= 70:
                review.append(row)

        for variant in tour.get("variants") or []:
            variant_targets = [str(variant.get("name") or "")]
            entry, score, second, source_name, target_name = best_entry(
                city, variant_targets, entries
            )
            if not entry:
                continue
            row = mapping_row(
                tour,
                entry,
                score,
                second,
                source_name,
                target_name,
                variant=variant,
            )
            if is_accepted(row):
                accepted_variants.append(row)
            elif score >= 70:
                review.append(row)

    accepted_tours = [
        row for row in accepted_tours if row["tourId"] not in EXCLUDED_TOURS
    ]
    accepted_variants = [
        row
        for row in accepted_variants
        if (row["tourId"], row["variantId"]) not in EXCLUDED_VARIANTS
    ]

    entry_lookup = {
        (str(entry["city"]), int(entry["sourceRow"])): entry for entry in entries
    }
    tour_lookup = {str(tour["id"]): tour for tour in tours}
    variant_keys = {
        (str(row["tourId"]), str(row["variantId"])): index
        for index, row in enumerate(accepted_variants)
    }
    for key, source_key in MANUAL_VARIANTS.items():
        tour = tour_lookup.get(key[0])
        entry = entry_lookup.get(source_key)
        if not tour or not entry:
            raise SystemExit(f"Manual mapping target not found: {key} -> {source_key}")
        variant = next(
            (
                row
                for row in tour.get("variants") or []
                if str(row.get("id")) == key[1]
            ),
            None,
        )
        if not variant:
            raise SystemExit(f"Manual variant not found: {key}")
        row = mapping_row(
            tour,
            entry,
            100.0,
            0.0,
            str(entry["englishName"] or entry["russianName"]),
            str(variant["name"]),
            variant=variant,
        )
        existing_index = variant_keys.get(key)
        if existing_index is None:
            variant_keys[key] = len(accepted_variants)
            accepted_variants.append(row)
        else:
            accepted_variants[existing_index] = row

    accepted_tours.sort(key=lambda row: str(row["tourId"]))
    accepted_variants.sort(
        key=lambda row: (str(row["tourId"]), str(row["variantId"]))
    )
    report = {
        "sourceSpreadsheetId": source["sourceSpreadsheetId"],
        "tourMappings": accepted_tours,
        "variantMappings": accepted_variants,
        "review": sorted(
            review,
            key=lambda row: (str(row["tourId"]), str(row.get("variantId") or "")),
        ),
        "excludedTours": sorted(EXCLUDED_TOURS),
        "excludedVariants": [
            {"tourId": tour_id, "variantId": variant_id}
            for tour_id, variant_id in sorted(EXCLUDED_VARIANTS)
        ],
    }
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if args.data_output:
        args.data_output.parent.mkdir(parents=True, exist_ok=True)
        compact = {
            "sourceSpreadsheetId": source["sourceSpreadsheetId"],
            "tourMappings": {
                str(row["tourId"]): {
                    "sourceSheet": row["sourceSheet"],
                    "sourceRow": row["sourceRow"],
                    "englishName": row["englishName"],
                    "russianName": row["russianName"],
                    "whatToTake": row["whatToTake"],
                }
                for row in accepted_tours
            },
            "variantMappings": {},
        }
        for row in accepted_variants:
            tour_id = str(row["tourId"])
            variant_id = str(row["variantId"])
            compact["variantMappings"].setdefault(tour_id, {})[variant_id] = {
                "sourceSheet": row["sourceSheet"],
                "sourceRow": row["sourceRow"],
                "englishName": row["englishName"],
                "russianName": row["russianName"],
                "whatToTake": row["whatToTake"],
            }
        args.data_output.write_text(
            json.dumps(compact, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
    print(
        json.dumps(
            {
                "tourMappings": len(accepted_tours),
                "variantMappings": len(accepted_variants),
                "review": len(review),
            },
            ensure_ascii=False,
        )
    )


if __name__ == "__main__":
    main()
