#!/usr/bin/env python3
"""
Analyse et enrichit le maillage interne des articles WordPress.

Pour chaque article publié, repère les articles connexes (catégories, tags,
mots du titre) vers lesquels il n'existe pas encore de lien, puis insère
une ancre contextuelle dans le contenu.

Usage :
  python3 scripts/wp_internal_linking.py --analyze
  python3 scripts/wp_internal_linking.py --dry-run
  python3 scripts/wp_internal_linking.py --apply
  python3 scripts/wp_internal_linking.py --apply --limit 10
"""

from __future__ import annotations

import argparse
import base64
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from html import unescape
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT / "scripts") not in sys.path:
    sys.path.insert(0, str(ROOT / "scripts"))

from article_studio.config import load_config  # noqa: E402

DOMAIN = "arnaud-merigeau.fr"

GENERIC_ANCHORS = {
    "commerce", "e-commerce", "boutique", "site", "web", "contenu", "outils",
    "articles", "temps", "pages", "fichiers", "modules", "optimisations",
    "conformité", "des fichiers", "les pages", "les modules", "votre boutique",
    "pour prestashop", "avec php", "back office", "back-office", "prestashop",
    "wordpress", "woocommerce", "ligne", "client", "clients", "produit",
    "produits", "vente", "ventes", "trafic", "seo", "theme", "thème",
}

MIN_INLINE_CHARS = 12
MIN_INLINE_WORDS = 2

STOP_WORDS = {
    "a", "à", "au", "aux", "avec", "ce", "ces", "cette", "comment", "d", "dans",
    "de", "des", "du", "en", "et", "est", "être", "for", "il", "la", "le", "les",
    "leur", "lors", "mais", "ne", "nos", "notre", "on", "ou", "par", "pas", "plus",
    "pour", "que", "qui", "quoi", "sa", "se", "ses", "son", "sur", "the", "un",
    "une", "vos", "votre", "wordpress", "prestashop", "site", "web", "blog",
    "guide", "article", "comment", "pourquoi", "tout", "tous", "toutes", "via",
    "chez", "sans", "sous", "entre", "afin", "comme", "être", "avoir", "faire",
    "2024", "2025", "2026", "2023", "2022", "2021", "2020", "2019", "2018",
}

LINK_RE = re.compile(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>', re.I)
TAG_SPLIT_RE = re.compile(r"(<[^>]+>)")


@dataclass
class Post:
    id: int
    slug: str
    link: str
    title: str
    content: str
    categories: list[str] = field(default_factory=list)
    tags: list[str] = field(default_factory=list)
    focus_kw: str = ""
    linked_urls: set[str] = field(default_factory=set)
    linked_slugs: set[str] = field(default_factory=set)


def strip_html(text: str) -> str:
    return unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", text or "")).strip())


def title_words(title: str) -> set[str]:
    words = re.findall(r"[a-zàâäéèêëïîôùûüç0-9-]{3,}", title.lower())
    return {w for w in words if w not in STOP_WORDS}


def phrase_candidates(post: Post) -> list[str]:
    candidates: list[str] = []
    title_clean = strip_html(post.title)

    if post.focus_kw and len(post.focus_kw) >= 4:
        candidates.append(post.focus_kw.strip())

    for tag in post.tags:
        if len(tag) >= 4:
            candidates.append(tag)

    title_parts = re.split(r"[:—–\-|]", title_clean)
    for part in title_parts:
        part = part.strip()
        if len(part) >= 8:
            candidates.append(part)

    words = [w for w in re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ0-9-]+", title_clean) if len(w) >= 3]
    for n in (4, 3, 2):
        for i in range(len(words) - n + 1):
            chunk = " ".join(words[i : i + n])
            if len(chunk) >= 8 and chunk.lower() not in STOP_WORDS:
                candidates.append(chunk)

    for w in sorted(title_words(title_clean), key=len, reverse=True):
        if len(w) >= 5:
            candidates.append(w)

    seen: set[str] = set()
    unique: list[str] = []
    for c in candidates:
        key = c.lower()
        if key not in seen:
            seen.add(key)
            unique.append(c)
    return unique


def normalize_url(url: str) -> str:
    return url.split("?")[0].split("#")[0].rstrip("/")


def extract_internal_links(html: str) -> tuple[set[str], set[str]]:
    urls: set[str] = set()
    slugs: set[str] = set()
    for href in LINK_RE.findall(html or ""):
        if DOMAIN in href or href.startswith("/"):
            norm = normalize_url(href)
            urls.add(norm)
            m = re.search(r"/([^/]+)/?$", norm)
            if m:
                slugs.add(m.group(1))
    return urls, slugs


def is_already_linked(source: Post, target: Post) -> bool:
    target_url = normalize_url(target.link)
    if target_url in source.linked_urls:
        return True
    if target.slug in source.linked_slugs:
        return True
    if target.id == source.id:
        return True
    return False


def relevance_score(source: Post, target: Post) -> float:
    if source.id == target.id:
        return 0.0
    score = 0.0
    common_cats = set(source.categories) & set(target.categories)
    score += len(common_cats) * 4.0
    common_tags = set(source.tags) & set(target.tags)
    score += len(common_tags) * 3.0
    sw = title_words(source.title)
    tw = title_words(target.title)
    overlap = sw & tw
    score += len(overlap) * 2.5
    if source.categories and target.categories and source.categories[0] == target.categories[0]:
        score += 2.0
    if source.focus_kw and source.focus_kw.lower() in strip_html(target.title).lower():
        score += 2.0
    if target.focus_kw and target.focus_kw.lower() in strip_html(source.title).lower():
        score += 2.0
    return score


def is_good_inline_anchor(phrase: str) -> bool:
    clean = phrase.strip()
    if len(clean) < MIN_INLINE_CHARS:
        return False
    words = re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ0-9-]+", clean)
    if len(words) < MIN_INLINE_WORDS and clean.lower() in GENERIC_ANCHORS:
        return False
    if clean.lower() in GENERIC_ANCHORS:
        return False
    if len(words) == 1 and len(words[0]) < 10:
        return False
    return True


def compile_phrase_pattern(phrase: str) -> re.Pattern[str]:
    if " " in phrase:
        return re.compile(re.escape(phrase), re.IGNORECASE)
    return re.compile(r"\b" + re.escape(phrase) + r"\b", re.IGNORECASE)


def wrap_phrase_with_link(html: str, phrase: str, url: str) -> tuple[str, bool]:
    if not is_good_inline_anchor(phrase):
        return html, False
    if normalize_url(url) in {normalize_url(u) for u in LINK_RE.findall(html)}:
        return html, False

    pattern = compile_phrase_pattern(phrase)
    parts = TAG_SPLIT_RE.split(html)
    in_anchor = False
    modified = False
    out: list[str] = []

    for part in parts:
        if part.startswith("<"):
            if re.match(r"<a[\s>]", part, re.I):
                in_anchor = True
            elif re.match(r"</a>", part, re.I):
                in_anchor = False
            out.append(part)
            continue

        if in_anchor or modified:
            out.append(part)
            continue

        m = pattern.search(part)
        if m:
            matched = part[m.start() : m.end()]
            linked = f'<a href="{url}">{matched}</a>'
            part = part[: m.start()] + linked + part[m.end() :]
            modified = True
        out.append(part)

    return "".join(out), modified


def append_see_also_paragraph(html: str, target: Post) -> str:
    title = strip_html(target.title)
    url = target.link
    block = (
        f'\n<p>Pour approfondir ce sujet, consultez notre article '
        f'<a href="{url}">{title}</a>.</p>\n'
    )
    if "<!--more-->" in html:
        return html.replace("<!--more-->", f"<!--more-->{block}", 1)
    return html + block


def insert_link(source: Post, target: Post) -> tuple[str, str, str] | None:
    """Retourne (new_content, anchor, method) ou None si impossible."""
    url = target.link
    for phrase in phrase_candidates(target):
        if not is_good_inline_anchor(phrase):
            continue
        if phrase.lower() not in strip_html(source.content).lower():
            continue
        new_html, ok = wrap_phrase_with_link(source.content, phrase, url)
        if ok:
            return new_html, phrase, "inline"

    title = strip_html(target.title)
    if len(title) < 20:
        title = f"« {title} »"
    new_html = append_see_also_paragraph(source.content, target)
    return new_html, title, "see-also"


def wp_request(method: str, url: str, user: str, password: str, payload: dict | None = None) -> dict:
    headers = {
        "Accept": "application/json",
        "User-Agent": "wp-internal-linking/1.0",
        "Authorization": f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}",
    }
    data = json.dumps(payload).encode("utf-8") if payload is not None else None
    if payload is not None:
        headers["Content-Type"] = "application/json"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            raw = resp.read().decode()
            return json.loads(raw) if raw else {}
    except urllib.error.HTTPError as e:
        body = e.read().decode(errors="replace")
        raise RuntimeError(f"WordPress HTTP {e.code}: {body[:300]}") from e


def fetch_all_posts(base_url: str, user: str, password: str) -> list[Post]:
    posts: list[Post] = []
    page = 1
    while True:
        q = urllib.parse.urlencode(
            {
                "per_page": 100,
                "page": page,
                "status": "publish",
                "context": "edit",
                "_embed": "1",
            }
        )
        url = f"{base_url.rstrip('/')}/wp-json/wp/v2/posts?{q}"
        headers = {
            "Accept": "application/json",
            "Authorization": f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}",
        }
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=120) as resp:
            batch = json.loads(resp.read().decode())
            total_pages = int(resp.headers.get("X-WP-TotalPages", 1))

        for p in batch:
            urls, slugs = extract_internal_links(
                p.get("content", {}).get("raw") or p.get("content", {}).get("rendered", "")
            )
            cats, tags = [], []
            for group in (p.get("_embedded") or {}).get("wp:term") or []:
                for t in group:
                    if t.get("taxonomy") == "category":
                        cats.append(t.get("name", ""))
                    elif t.get("taxonomy") == "post_tag":
                        tags.append(t.get("name", ""))
            meta = p.get("meta") or {}
            posts.append(
                Post(
                    id=int(p["id"]),
                    slug=str(p["slug"]),
                    link=str(p["link"]).rstrip("/"),
                    title=strip_html(p.get("title", {}).get("rendered", "")),
                    content=p.get("content", {}).get("raw")
                    or p.get("content", {}).get("rendered", ""),
                    categories=cats,
                    tags=tags,
                    focus_kw=str(meta.get("_yoast_wpseo_focuskw") or meta.get("yoast_wpseo_focuskw") or ""),
                    linked_urls=urls,
                    linked_slugs=slugs,
                )
            )

        if page >= total_pages:
            break
        page += 1
    return posts


def find_suggestions(
    posts: list[Post],
    *,
    max_links_per_post: int,
    min_score: float,
) -> list[dict]:
    suggestions: list[dict] = []
    for source in posts:
        candidates: list[tuple[float, Post]] = []
        for target in posts:
            if is_already_linked(source, target):
                continue
            score = relevance_score(source, target)
            if score >= min_score:
                candidates.append((score, target))
        candidates.sort(key=lambda x: x[0], reverse=True)

        added = 0
        working_content = source.content
        working_source = Post(
            id=source.id,
            slug=source.slug,
            link=source.link,
            title=source.title,
            content=working_content,
            categories=source.categories,
            tags=source.tags,
            focus_kw=source.focus_kw,
            linked_urls=set(source.linked_urls),
            linked_slugs=set(source.linked_slugs),
        )

        for score, target in candidates:
            if added >= max_links_per_post:
                break
            result = insert_link(working_source, target)
            if not result:
                continue
            new_content, anchor, method = result
            if new_content == working_source.content:
                continue
            suggestions.append(
                {
                    "source_id": source.id,
                    "source_title": source.title,
                    "source_link": source.link,
                    "target_id": target.id,
                    "target_title": target.title,
                    "target_link": target.link,
                    "score": round(score, 1),
                    "anchor": anchor,
                    "method": method,
                    "new_content": new_content,
                }
            )
            working_source.content = new_content
            working_source.linked_urls.add(normalize_url(target.link))
            working_source.linked_slugs.add(target.slug)
            added += 1

    return suggestions


def merge_suggestions_by_post(suggestions: list[dict]) -> dict[int, dict]:
    by_post: dict[int, dict] = {}
    for s in suggestions:
        pid = s["source_id"]
        if pid not in by_post:
            by_post[pid] = {
                "source_id": pid,
                "source_title": s["source_title"],
                "source_link": s["source_link"],
                "links": [],
                "new_content": s["new_content"],
            }
        else:
            by_post[pid]["new_content"] = s["new_content"]
        by_post[pid]["links"].append(
            {
                "target_id": s["target_id"],
                "target_title": s["target_title"],
                "target_link": s["target_link"],
                "anchor": s["anchor"],
                "method": s["method"],
                "score": s["score"],
            }
        )
    return by_post


def analyze(posts: list[Post]) -> dict:
    no_links = [p for p in posts if not p.linked_urls]
    few_links = [p for p in posts if len(p.linked_urls) < 2]
    return {
        "total_posts": len(posts),
        "total_internal_links": sum(len(p.linked_urls) for p in posts),
        "posts_without_internal_links": len(no_links),
        "posts_with_fewer_than_2_links": len(few_links),
        "sample_no_links": [{"id": p.id, "title": p.title} for p in no_links[:15]],
    }


def main() -> int:
    ap = argparse.ArgumentParser(description="Maillage interne intelligent WordPress")
    ap.add_argument("--analyze", action="store_true", help="Statistiques uniquement")
    ap.add_argument("--dry-run", action="store_true", help="Propositions sans modification")
    ap.add_argument("--apply", action="store_true", help="Appliquer les liens sur WordPress")
    ap.add_argument("--max-links", type=int, default=2, help="Liens max ajoutés par article")
    ap.add_argument("--min-score", type=float, default=6.0, help="Score minimal de pertinence")
    ap.add_argument("--limit", type=int, default=0, help="Limiter le nombre d'articles modifiés")
    ap.add_argument("--output", type=Path, help="Exporter le rapport JSON")
    ap.add_argument("--delay", type=float, default=0.5, help="Pause entre requêtes WP (s)")
    args = ap.parse_args()

    if not (args.analyze or args.dry_run or args.apply):
        ap.error("Indiquez --analyze, --dry-run ou --apply")

    cfg = load_config()
    if not cfg.wp_user or not cfg.wp_application_password:
        print("Identifiants WordPress manquants dans .secrets/.env", file=sys.stderr)
        return 1

    print("Récupération des articles…", file=sys.stderr)
    posts = fetch_all_posts(cfg.wp_base_url, cfg.wp_user, cfg.wp_application_password)
    print(f"{len(posts)} articles publiés.", file=sys.stderr)

    stats = analyze(posts)
    print(json.dumps(stats, ensure_ascii=False, indent=2))

    if args.analyze:
        return 0

    suggestions = find_suggestions(
        posts,
        max_links_per_post=args.max_links,
        min_score=args.min_score,
    )
    by_post = merge_suggestions_by_post(suggestions)

    report = {
        "stats_before": stats,
        "articles_to_update": len(by_post),
        "links_to_add": len(suggestions),
        "updates": [
            {
                "source_id": v["source_id"],
                "source_title": v["source_title"],
                "source_link": v["source_link"],
                "links_added": v["links"],
            }
            for v in by_post.values()
        ],
    }

    out_path = args.output or ROOT / "exports" / "internal-linking" / "report.json"
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"\nRapport : {out_path}", file=sys.stderr)
    print(f"Articles à mettre à jour : {len(by_post)}", file=sys.stderr)
    print(f"Liens à ajouter : {len(suggestions)}", file=sys.stderr)

    for v in list(by_post.values())[:20]:
        print(f"\n— [{v['source_id']}] {v['source_title']}")
        for link in v["links"]:
            print(
                f"   → [{link['target_id']}] « {link['anchor']} » "
                f"({link['method']}, score {link['score']})"
            )

    if args.dry_run:
        return 0

    updates = list(by_post.values())
    if args.limit:
        updates = updates[: args.limit]

    ok, err = 0, 0
    for item in updates:
        url = f"{cfg.wp_base_url.rstrip('/')}/wp-json/wp/v2/posts/{item['source_id']}"
        try:
            wp_request(
                "POST",
                url,
                cfg.wp_user,
                cfg.wp_application_password,
                {"content": item["new_content"]},
            )
            ok += 1
            print(f"✓ [{item['source_id']}] {item['source_title']}", file=sys.stderr)
        except RuntimeError as e:
            err += 1
            print(f"✗ [{item['source_id']}] {e}", file=sys.stderr)
        time.sleep(args.delay)

    print(f"\nTerminé : {ok} mis à jour, {err} erreurs.", file=sys.stderr)
    return 0 if err == 0 else 1


if __name__ == "__main__":
    raise SystemExit(main())
