#!/usr/bin/env python3
"""Tests du generateur SEO trilingue (Sprint 6 · SEO).

Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea.
La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente.

Axes centraux :
  - VOLUME/COUVERTURE : 200+ mots-cles, 3 langues, min par langue (roadmap L60) ;
  - ANTI-INVENTION (#6) : chaque mot-cle est sourcE ; aucun chiffre fabrique ;
    schema.org n'expose un prix que pour un projet 'disponible' sourcE (USD #10) ;
  - hreflang : alternates completes + x-default + canonical coherents.
Chaque invariant anti-invention a une injection negative dediee.
"""

from __future__ import annotations

import copy
import json
import os
import subprocess
import sys
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, ".."))

sys.path.insert(0, _MODULE)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))

from seolib import builder, deps, keywords, schemaorg  # noqa: E402
import seo_gen as gen  # noqa: E402

try:
    import jsonschema  # type: ignore
    _HAS_JSONSCHEMA = True
except Exception:  # pragma: no cover
    _HAS_JSONSCHEMA = False


def _fresh():
    spec = deps.load_spec()
    master = deps.load_master()
    bundle = builder.build_bundle(spec, master)
    return spec, master, bundle


class BuildAndSchema(unittest.TestCase):
    def setUp(self):
        self.spec, self.master, self.bundle = _fresh()

    def test_validate_passes_on_fixture(self):
        self.assertEqual(gen._validate(self.spec, self.master, self.bundle), [])

    def test_fixture_conforms_to_publiciste_contract(self):
        self.assertEqual(deps.validate_master(self.master), [])

    def test_output_schema_valid(self):
        schema = deps.load_json(deps.SCHEMA_PATH)
        self.assertEqual(deps.validate(self.bundle, schema), [])

    @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
    def test_output_schema_valid_oracle(self):
        schema = deps.load_json(deps.SCHEMA_PATH)
        jsonschema.validate(self.bundle, schema)  # ne leve pas => OK

    def test_deterministic(self):
        b2 = builder.build_bundle(self.spec, self.master)
        self.assertEqual(json.dumps(self.bundle, sort_keys=True),
                         json.dumps(b2, sort_keys=True))

    def test_cli_build_and_validate(self):
        env = dict(os.environ)
        out = os.path.join(_HERE, "_tmp_out")
        r = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
                            "build", "-o", out], capture_output=True, text=True, env=env)
        self.assertEqual(r.returncode, 0, r.stderr)
        for f in ("seo_keywords.json", "seo_schema_org.json", "seo_hreflang.json",
                  "MANIFEST.json"):
            self.assertTrue(os.path.exists(os.path.join(out, f)))
        r2 = subprocess.run([sys.executable, os.path.join(_MODULE, "seo_gen.py"),
                             "validate"], capture_output=True, text=True, env=env)
        self.assertEqual(r2.returncode, 0, r2.stderr)


class Keywords(unittest.TestCase):
    def setUp(self):
        self.spec, self.master, self.bundle = _fresh()
        self.kws = self.bundle["keywords"]

    def test_volume_target(self):
        self.assertGreaterEqual(len(self.kws), self.spec["targets"]["min_keywords_total"])

    def test_three_langs_min_each(self):
        for lang in self.spec["site"]["langs"]:
            n = sum(1 for k in self.kws if k["lang"] == lang)
            self.assertGreaterEqual(n, self.spec["targets"]["min_keywords_per_lang"], lang)

    def test_unique_term_lang(self):
        keys = [(k["term"], k["lang"]) for k in self.kws]
        self.assertEqual(len(keys), len(set(keys)))

    def test_every_keyword_sourced_and_resolvable(self):
        by_code = {p["code"] for p in self.master["projets"]}
        for k in self.kws:
            self.assertTrue(k["sources"], k["term"])
            for s in k["sources"]:
                if s.startswith("projet:"):
                    self.assertIn(s.split(".")[0].split(":")[1], by_code)
                else:
                    self.assertTrue(s.startswith("lexicon:"), s)

    def test_no_fabricated_digit_in_terms(self):
        by_code = {p["code"]: p for p in self.master["projets"]}
        for k in self.kws:
            td = deps.digits(k["term"])
            if not td:
                continue
            allowed = set()
            if k["projet"] in by_code:
                p = by_code[k["projet"]]
                allowed = deps.digits(p["nom"] + " " + p["localisation"])
            self.assertTrue(td <= allowed, k["term"])

    def test_numeric_project_name_survives(self):
        # P09 « 1069 Crisfer » : les chiffres du nom documente sont autorises.
        terms = {k["term"] for k in self.kws if k["projet"] == "P09"}
        self.assertIn("1069 Crisfer", terms)

    def test_confotur_global_keyword_present_when_documented(self):
        fr = {k["term"] for k in self.kws if k["lang"] == "fr" and k["category"] == "confotur"}
        self.assertTrue(any("CONFOTUR" in t for t in fr))

    def test_confotur_absent_when_not_documented(self):
        master = copy.deepcopy(self.master)
        for p in master["projets"]:
            p["inclus"] = []
        kws = keywords.generate(self.spec, master)
        self.assertFalse([k for k in kws if k["category"] == "confotur"])

    def test_negative_fabricated_digit_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["keywords"][0]["term"] = bundle["keywords"][0]["term"] + " 250000"
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("invention interdite" in e for e in errs), errs)

    def test_negative_unresolvable_source_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["keywords"][0]["sources"] = ["projet:P99.nom"]
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("non resoluble" in e for e in errs), errs)

    def test_negative_duplicate_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["keywords"].append(copy.deepcopy(bundle["keywords"][0]))
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("duplique" in e for e in errs), errs)

    def test_negative_below_target_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["keywords"] = bundle["keywords"][:10]
        bundle["manifest"]["counts"]["keywords_total"] = 10
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("cible" in e for e in errs), errs)


class SchemaOrg(unittest.TestCase):
    def setUp(self):
        self.spec, self.master, self.bundle = _fresh()
        self.graph = self.bundle["schema_org"]["@graph"]

    def test_context(self):
        self.assertEqual(self.bundle["schema_org"]["@context"], "https://schema.org")

    def test_one_org_and_one_listing_per_project(self):
        orgs = [n for n in self.graph if n["@type"] == "Organization"]
        listings = [n for n in self.graph if n["@type"] == "Residence"]
        self.assertEqual(len(orgs), 1)
        self.assertEqual(len(listings), len(self.master["projets"]))
        names = {n["name"] for n in listings}
        self.assertEqual(names, {p["nom"] for p in self.master["projets"]})

    def test_locality_and_url_from_source(self):
        by_name = {n["name"]: n for n in self.graph if n["@type"] == "Residence"}
        for p in self.master["projets"]:
            node = by_name[p["nom"]]
            self.assertEqual(node["address"]["addressLocality"], p["localisation"])
            self.assertTrue(node["url"].startswith(self.spec["site"]["base_url"]))

    def test_no_offers_for_en_developpement(self):
        offers = sum(len(n.get("offers", [])) for n in self.graph)
        self.assertEqual(offers, 0)

    @staticmethod
    def _master_with_dispo(price=199000.0):
        """Rend P02 'disponible' avec une typologie a prix sourcE (reste conforme
        au contrat Publiciste : disponible => prix numerique)."""
        spec = deps.load_spec()
        master = deps.load_master()
        p = next(x for x in master["projets"] if x["code"] == "P02")
        p["statut"] = "disponible"
        p["typologies"] = [{"nom": "Studio", "prix_depuis_usd": price}]
        return spec, master

    def test_disponible_with_sourced_price_emits_usd_offer(self):
        spec, master = self._master_with_dispo()
        bundle = builder.build_bundle(spec, master)
        self.assertEqual(gen._validate(spec, master, bundle), [])
        node = [n for n in bundle["schema_org"]["@graph"]
                if n.get("name") == "Coral del Sur"][0]
        self.assertEqual(len(node["offers"]), 1)
        self.assertEqual(node["offers"][0]["priceCurrency"], "USD")
        self.assertEqual(node["offers"][0]["price"], 199000.0)

    def test_disponible_without_price_emits_no_offer(self):
        # Cas de bordure au niveau du generateur schema.org (le contrat amont
        # interdit deja disponible sans prix) : aucune offre fabriquee.
        projet = {"code": "P02", "nom": "X", "statut": "disponible",
                  "localisation": "Y", "typologies": [{"nom": "S", "prix_depuis_usd": None}]}
        self.assertEqual(schemaorg._offers_for(projet), [])

    def test_negative_offer_on_en_developpement_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        node = [n for n in bundle["schema_org"]["@graph"] if n["@type"] == "Residence"][0]
        node["offers"] = [{"@type": "Offer", "price": 100000, "priceCurrency": "USD",
                           "availability": "https://schema.org/InStock"}]
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("offre interdite" in e or "presence d'offre" in e for e in errs), errs)

    def test_negative_wrong_currency_flagged(self):
        spec, master = self._master_with_dispo()
        bundle = builder.build_bundle(spec, master)
        node = [n for n in bundle["schema_org"]["@graph"]
                if n.get("name") == "Coral del Sur"][0]
        node["offers"][0]["priceCurrency"] = "EUR"
        errs = gen._validate(spec, master, bundle)
        self.assertTrue(any("devise" in e for e in errs), errs)

    def test_negative_fabricated_price_flagged(self):
        spec, master = self._master_with_dispo()
        bundle = builder.build_bundle(spec, master)
        node = [n for n in bundle["schema_org"]["@graph"]
                if n.get("name") == "Coral del Sur"][0]
        node["offers"][0]["price"] = 999999  # non sourcE
        errs = gen._validate(spec, master, bundle)
        self.assertTrue(any("non sourcE" in e for e in errs), errs)


class Hreflang(unittest.TestCase):
    def setUp(self):
        self.spec, self.master, self.bundle = _fresh()
        self.href = self.bundle["hreflang"]

    def test_one_page_per_project_plus_home(self):
        names = {pg["page"] for pg in self.href["pages"]}
        self.assertEqual(names, {"home"} | {p["code"] for p in self.master["projets"]})

    def test_alternates_complete_and_xdefault(self):
        langs = self.spec["site"]["langs"]
        default = self.spec["site"]["default_lang"]
        for pg in self.href["pages"]:
            alts = {a["hreflang"]: a["href"] for a in pg["alternates"]}
            self.assertEqual(set(alts), set(langs) | {"x-default"})
            self.assertEqual(alts["x-default"], alts[default])
            self.assertEqual(pg["canonical"], alts[default])

    def test_all_urls_under_base(self):
        base = self.spec["site"]["base_url"].rstrip("/")
        for pg in self.href["pages"]:
            self.assertTrue(pg["canonical"].startswith(base))
            for a in pg["alternates"]:
                self.assertTrue(a["href"].startswith(base))

    def test_lang_prefix_applied(self):
        home = [pg for pg in self.href["pages"] if pg["page"] == "home"][0]
        alts = {a["hreflang"]: a["href"] for a in home["alternates"]}
        self.assertEqual(alts["fr"], "https://vente.otov7.com/")
        self.assertEqual(alts["en"], "https://vente.otov7.com/en/")
        self.assertEqual(alts["es"], "https://vente.otov7.com/es/")

    def test_negative_broken_xdefault_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        for a in bundle["hreflang"]["pages"][0]["alternates"]:
            if a["hreflang"] == "x-default":
                a["href"] = "https://vente.otov7.com/zz/"
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("x-default" in e for e in errs), errs)

    def test_negative_missing_alternate_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["hreflang"]["pages"][0]["alternates"] = [
            a for a in bundle["hreflang"]["pages"][0]["alternates"] if a["hreflang"] != "es"
        ]
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("alternates" in e for e in errs), errs)


class Manifest(unittest.TestCase):
    def setUp(self):
        self.spec, self.master, self.bundle = _fresh()

    def test_counts_coherent(self):
        c = self.bundle["manifest"]["counts"]
        self.assertEqual(c["keywords_total"], len(self.bundle["keywords"]))
        self.assertEqual(c["projects"], len(self.master["projets"]))
        self.assertEqual(c["hreflang_pages"], len(self.bundle["hreflang"]["pages"]))
        self.assertEqual(sum(c["keywords_per_lang"].values()), c["keywords_total"])

    def test_negative_tampered_count_flagged(self):
        bundle = copy.deepcopy(self.bundle)
        bundle["manifest"]["counts"]["keywords_total"] = 7
        errs = gen._validate(self.spec, self.master, bundle)
        self.assertTrue(any("manifest.counts" in e for e in errs), errs)

    def test_slugify(self):
        self.assertEqual(deps.slugify("Aqua Terra Las Terrenas"), "aqua-terra-las-terrenas")
        self.assertEqual(deps.slugify("1069 Crisfer"), "1069-crisfer")
        self.assertEqual(deps.slugify("Xamana Cantiles"), "xamana-cantiles")


if __name__ == "__main__":
    unittest.main(verbosity=2)
