"""
QA Autopilot — L'intercepteur Playwright intelligent
=====================================================
Par Julien Mer / JMer Consulting

Remplace 23 000 lignes de MCP par ~600 lignes de Python.
Écoute en parallèle de tes tests Playwright, diagnostique
les échecs et propose le fix. Zéro MCP, zéro usine à gaz.

Pipeline :
  1. Hook transparent sur la page Playwright (events natifs)
  2. Capture en parallèle : DOM (listener JS), réseau, console, screenshots
  3. Si le test fail → bundle tout le contexte
  4. Envoie UN prompt à l'IA → diagnostic + fix
  5. Génère le rapport + patch suggéré

Usage :
  # Mode pytest (recommandé) — copier conftest_qa.py dans ton projet
  pytest tests/ --qa-autopilot

  # Mode standalone
  python qa_autopilot.py tests/test_checkout.py

  # Mode import direct
  from qa_autopilot import QAInterceptor
  interceptor = QAInterceptor(page)
  interceptor.start()
  # ... ton test ...
  interceptor.diagnose(error, "test_file.py")

Prérequis :
  pip install playwright openai
  playwright install chromium

Variables d'environnement :
  OPENAI_API_KEY=sk-...
  QA_MODEL=gpt-4.1-mini        (optionnel, défaut: gpt-4.1-mini)
  QA_SCREENSHOT=1               (optionnel, inclut le screenshot dans le prompt)
"""

import json
import os
import locale
import time
try:
    from deep_translator import GoogleTranslator as _GT
    def _t(text):
        """Traduit un label dans la langue active — silencieux si échec"""
        lang = get_qa_lang()
        if not lang or lang.startswith("fr") or lang.startswith("en"):
            return text
        try:
            target = lang.replace("_", "-").split("-")[0]  # zh_CN → zh
            if lang.lower().startswith("zh"):
                target = "zh-CN"
            return _GT(source="auto", target=target).translate(text)
        except Exception:
            return text
except ImportError:
    def _t(text):
        return text
try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass  # python-dotenv optionnel — pas bloquant sans lui
import base64
import traceback
from datetime import datetime
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional


# ============================================================
# CONFIGURATION
# ============================================================

LLM_MODEL = os.getenv("QA_MODEL", "gpt-4.1-mini")  # ou deepseek-chat, ollama, etc.
INCLUDE_SCREENSHOT = os.getenv("QA_SCREENSHOT", "0") == "1"
REPORT_DIR = Path(os.getenv("QA_REPORT_DIR", "qa-reports"))

# Langue des diagnostics IA
# QA_LANG=off       → le LLM décide seul (anglais par défaut)
# QA_LANG=zh_CN     → force le chinois
# QA_LANG=auto      → détection automatique de la locale système (défaut)
# Peut aussi être overridé par --qa-lang en paramètre pytest
_QA_LANG_OVERRIDE = None  # sera setté par pytest_configure si --qa-lang fourni

def get_qa_lang():
    """Retourne la langue active — priorité : --qa-lang CLI > QA_LANG env > locale système"""
    import sys
    # Lire directement sys.argv — fiable quelle que soit la façon dont les hooks sont chargés
    for i, arg in enumerate(sys.argv):
        if arg.startswith("--qa-lang="):
            val = arg.split("=", 1)[1]
            return None if val == "off" else (locale.getdefaultlocale()[0] or "en_US") if val == "auto" else val
        if arg == "--qa-lang" and i + 1 < len(sys.argv):
            val = sys.argv[i + 1]
            return None if val == "off" else (locale.getdefaultlocale()[0] or "en_US") if val == "auto" else val
    # Fallback sur variable d'environnement
    val = os.getenv("QA_LANG", "auto")
    if val == "off":
        return None
    if val == "auto":
        return locale.getdefaultlocale()[0] or "en_US"
    return val

def get_lang_name(lang_code):
    """Retourne le nom complet de la langue depuis son code — via deep_translator"""
    if not lang_code:
        return None
    try:
        from deep_translator import GoogleTranslator
        # Normaliser le code : fr_FR → fr, zh_CN → zh-CN
        code = lang_code.replace("_", "-")
        code_to_name = {v: k for k, v in GoogleTranslator().get_supported_languages(as_dict=True).items()}
        # Essai exact, puis prefix
        name = code_to_name.get(code) or code_to_name.get(code.split("-")[0])
        return name.title() if name else lang_code
    except Exception:
        return lang_code
MAX_NETWORK_BODY = 2000      # chars max pour body requête/réponse
MAX_CONSOLE_ENTRIES = 50      # dernières entrées console gardées
MAX_DOM_ACTIONS = 200         # actions DOM max dans le contexte IA


# ============================================================
# DOM LISTENER JS — Injection navigateur
# (cascade sélecteurs 6 tiers + shadow DOM + scroll)
# ============================================================

DOM_LISTENER_JS = """
(function() {
    if (window.__qaListenerInstalled) return;
    window.__qaListenerInstalled = true;

    function saveEntry(entry) {
        try {
            let log = JSON.parse(localStorage.getItem('__qaLocatorLog') || '[]');
            log.push(entry);
            localStorage.setItem('__qaLocatorLog', JSON.stringify(log));
        } catch(e) {}
    }

    function isInShadowDOM(el) {
        return el.getRootNode() instanceof ShadowRoot;
    }

    function getQuickSelector(el) {
        if (!el || !el.getAttribute) return 'unknown';
        if (el.getAttribute('data-testid')) return '[data-testid="' + el.getAttribute('data-testid') + '"]';
        if (el.id && !el.id.match(/^[0-9]|react|ember|__/)) return '#' + el.id;
        if (el.getAttribute('name')) return el.tagName.toLowerCase() + '[name="' + el.getAttribute('name') + '"]';
        if (el.getAttribute('aria-label')) return '[aria-label="' + el.getAttribute('aria-label') + '"]';
        let sel = el.tagName.toLowerCase();
        if (el.className && typeof el.className === 'string') {
            let cls = el.className.trim().split(/\\s+/).filter(c => c.length > 2 && c.length < 30).slice(0, 2);
            if (cls.length) sel += '.' + cls.join('.');
        }
        return sel;
    }

    function getShadowSelector(el) {
        let chain = [];
        let current = el;
        while (current) {
            let root = current.getRootNode();
            let localSel = getQuickSelector(current);
            if (root instanceof ShadowRoot) {
                chain.unshift({selector: localSel, shadow: true});
                current = root.host;
            } else {
                chain.unshift({selector: localSel, shadow: false});
                break;
            }
        }
        let pwSel = chain.map(c => c.selector).join(' >>> ');
        let jsSel = chain.map((c, i) => {
            if (i === 0) return 'document.querySelector("' + c.selector + '")';
            return '.shadowRoot.querySelector("' + c.selector + '")';
        }).join('');
        return { strategy:'shadow', value:pwSel, inShadowDOM:true, shadowChain:chain,
                 playwrightSelector:pwSel, jsSelector:jsSel, unique:true, matchCount:1 };
    }

    function getBestSelector(el) {
        if (!el || !el.getAttribute) return {strategy:'unknown', value:'unknown', inShadowDOM:false, unique:false, matchCount:0};
        if (isInShadowDOM(el)) return getShadowSelector(el);

        let best = null, strategy = 'unknown';

        // Tier 1 : Attributs stables
        if (el.getAttribute('data-testid')) { best = '[data-testid="' + el.getAttribute('data-testid') + '"]'; strategy = 'data-testid'; }
        else if (el.id && !el.id.match(/^[0-9]|react|ember|__|:/)) { best = '#' + el.id; strategy = 'id'; }
        else if (el.getAttribute('name')) { best = el.tagName.toLowerCase() + '[name="' + el.getAttribute('name') + '"]'; strategy = 'name'; }

        // Tier 2 : Sémantique
        if (!best && el.getAttribute('aria-label')) { best = '[aria-label="' + el.getAttribute('aria-label') + '"]'; strategy = 'aria-label'; }
        if (!best && el.getAttribute('placeholder')) { best = el.tagName.toLowerCase() + '[placeholder="' + el.getAttribute('placeholder') + '"]'; strategy = 'placeholder'; }
        if (!best && el.getAttribute('title')) { best = '[title="' + el.getAttribute('title') + '"]'; strategy = 'title'; }

        // Tier 3 : Href pour les liens
        if (!best && el.tagName === 'A' && el.getAttribute('href')) {
            let href = el.getAttribute('href');
            if (href !== '#' && href !== '/' && href !== 'javascript:void(0)' && href.length < 100) { best = 'a[href="' + href + '"]'; strategy = 'href'; }
        }

        // Tier 4 : Parent avec attribut stable
        if (!best) {
            let parent = el.closest('[aria-label], [data-testid], [title]');
            if (parent && parent !== el) {
                if (parent.getAttribute('aria-label')) { best = '[aria-label="' + parent.getAttribute('aria-label') + '"]'; strategy = 'parent-aria-label'; }
                else if (parent.getAttribute('data-testid')) { best = '[data-testid="' + parent.getAttribute('data-testid') + '"]'; strategy = 'parent-data-testid'; }
                else if (parent.getAttribute('title')) { best = '[title="' + parent.getAttribute('title') + '"]'; strategy = 'parent-title'; }
            }
        }

        // Tier 5 : Label associé (inputs)
        if (!best && ['INPUT','SELECT','TEXTAREA'].includes(el.tagName)) {
            let label = el.id ? document.querySelector('label[for="' + el.id + '"]') : null;
            if (!label) label = el.closest('label');
            if (label) {
                let txt = (label.textContent || '').trim().substring(0, 40);
                if (txt) { best = '//label[contains(text(),"' + txt + '")]//input'; strategy = 'label-xpath'; }
            }
        }

        // Tier 6 : CSS court + nth-of-type
        if (!best) {
            let sel = el.tagName.toLowerCase();
            if (el.className && typeof el.className === 'string') {
                let cls = el.className.trim().split(/\\s+/).filter(c => c.length > 2 && c.length < 30 && !c.match(/active|hover|focus|open|visible|show|selected|current/)).slice(0, 2);
                if (cls.length) sel += '.' + cls.join('.');
            }
            if (el.parentElement) {
                let same = Array.from(el.parentElement.children).filter(s => s.tagName === el.tagName);
                if (same.length > 1) sel += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
            }
            best = sel; strategy = 'css-short';
        }

        // Validation unicité
        let matchCount = 0;
        try {
            if (strategy === 'label-xpath' || best.startsWith('//')) {
                matchCount = document.evaluate(best, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
            } else { matchCount = document.querySelectorAll(best).length; }
        } catch(e) { matchCount = -1; }

        // Fallback XPath text si multi-match
        let text = (el.innerText || el.textContent || '').trim();
        if (matchCount !== 1 && text && text.length > 0 && text.length < 50 && text.indexOf('\\n') === -1) {
            let xp = '//' + el.tagName.toLowerCase() + '[contains(text(),"' + text.replace(/"/g, "'") + '")]';
            try {
                let xc = document.evaluate(xp, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength;
                if (xc === 1 || (xc > 0 && xc < matchCount)) { best = xp; strategy = 'xpath-text'; matchCount = xc; }
            } catch(e) {}
        }

        return { strategy:strategy, value:best, inShadowDOM:false, unique:matchCount===1, matchCount:matchCount };
    }

    function getRealTarget(e) {
        let el = e.composedPath && e.composedPath().length > 0 ? e.composedPath()[0] : e.target;
        let tags = ['BUTTON','A','INPUT','SELECT','TEXTAREA'];
        for (let d = 0; d < 5 && el; d++) {
            if (el.id || (el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('name')))) break;
            if (tags.includes(el.tagName)) break;
            if (el.getAttribute && el.getAttribute('aria-label')) break;
            let p = el.parentElement;
            if (!p) break;
            if (tags.includes(p.tagName) || p.id || (p.getAttribute && (p.getAttribute('data-testid') || p.getAttribute('aria-label')))) { el = p; break; }
            el = p;
        }
        return el;
    }

    document.addEventListener('click', (e) => {
        let el = getRealTarget(e), sel = getBestSelector(el);
        saveEntry({ action:'click', timestamp:Date.now(), tag:el.tagName,
            text:(el.innerText||el.textContent||'').trim().substring(0,80),
            selector:sel, url:location.href, inShadowDOM:sel.inShadowDOM,
            attributes:{ id:el.id||null, name:el.getAttribute?el.getAttribute('name'):null,
                type:el.getAttribute?el.getAttribute('type'):null, href:el.getAttribute?el.getAttribute('href'):null,
                'data-testid':el.getAttribute?el.getAttribute('data-testid'):null,
                'aria-label':el.getAttribute?el.getAttribute('aria-label'):null, role:el.getAttribute?el.getAttribute('role'):null }
        });
    }, true);

    document.addEventListener('input', (e) => {
        let el = getRealTarget(e), sel = getBestSelector(el);
        saveEntry({ action:'input', timestamp:Date.now(), tag:el.tagName, value:el.value||'',
            selector:sel, url:location.href, inShadowDOM:sel.inShadowDOM,
            attributes:{ id:el.id||null, name:el.getAttribute?el.getAttribute('name'):null,
                type:el.getAttribute?el.getAttribute('type'):null, placeholder:el.getAttribute?el.getAttribute('placeholder'):null,
                'data-testid':el.getAttribute?el.getAttribute('data-testid'):null, 'aria-label':el.getAttribute?el.getAttribute('aria-label'):null }
        });
    }, true);

    let _st=null, _sy=window.scrollY;
    window.addEventListener('scroll', () => {
        if(!_st) _sy=window.scrollY;
        clearTimeout(_st);
        _st=setTimeout(()=>{ let d=window.scrollY-_sy; if(Math.abs(d)>50) saveEntry({action:'scroll',timestamp:Date.now(),tag:'WINDOW',
            direction:d>0?'down':'up',deltaY:d,scrollY:window.scrollY,
            viewport:{width:innerWidth,height:innerHeight,docHeight:document.documentElement.scrollHeight},
            url:location.href,selector:{strategy:'window',value:'window.scrollTo(0,'+window.scrollY+')',inShadowDOM:false,unique:true,matchCount:1},
            inShadowDOM:false,attributes:{}}); _st=null; },250);
    }, true);

    console.log('[QA Autopilot] Listener actif sur ' + location.href);
})();
"""


# ============================================================
# STRUCTURES DE DONNÉES
# ============================================================

@dataclass
class CapturedRequest:
    url: str
    method: str
    resource_type: str
    timestamp: float
    status: Optional[int] = None
    status_text: Optional[str] = None
    duration_ms: Optional[float] = None
    request_body: Optional[str] = None
    response_body: Optional[str] = None
    failure: Optional[str] = None

    @property
    def failed(self):
        return self.failure is not None or (self.status and self.status >= 400)


@dataclass
class CapturedConsole:
    type: str         # log, warn, error, info
    text: str
    url: Optional[str] = None
    timestamp: float = 0


@dataclass
class FailureContext:
    """Tout le contexte d'un test en échec, prêt pour l'IA"""
    test_file: str = ""
    test_source: str = ""
    error_message: str = ""
    error_traceback: str = ""
    dom_actions: list = field(default_factory=list)
    network_log: list = field(default_factory=list)
    failed_requests: list = field(default_factory=list)
    console_errors: list = field(default_factory=list)
    console_warnings: list = field(default_factory=list)
    screenshot_b64: Optional[str] = None
    page_url: str = ""
    timestamp: str = ""


# ============================================================
# INTERCEPTEUR PRINCIPAL
# ============================================================

class QAInterceptor:
    """
    Intercepteur non-invasif pour page Playwright.
    Se branche en parallèle, capture tout, diagnostique sur échec.
    """

    def __init__(self, page, config=None):
        self.page = page
        self.config = config or {}
        self._requests: dict[str, CapturedRequest] = {}  # url+method → entry
        self._console: list[CapturedConsole] = []
        self._page_errors: list[str] = []
        self._started = False
        self._start_time = 0

    def start(self):
        """Branche les listeners — appel unique après création de la page"""
        if self._started:
            return
        self._started = True
        self._start_time = time.time()

        # Injecter le DOM listener
        try:
            self.page.evaluate(DOM_LISTENER_JS)
        except Exception:
            pass  # page pas encore chargée, on injecte via add_init_script

        # Réinjecter sur chaque navigation
        self.page.context.add_init_script(DOM_LISTENER_JS)

        # Capturer le réseau
        self.page.on("request", self._on_request)
        self.page.on("response", self._on_response)
        self.page.on("requestfailed", self._on_request_failed)

        # Capturer la console
        self.page.on("console", self._on_console)
        self.page.on("pageerror", self._on_page_error)

    def stop(self):
        """Décroche les listeners proprement"""
        if not self._started:
            return
        try:
            self.page.remove_listener("request", self._on_request)
            self.page.remove_listener("response", self._on_response)
            self.page.remove_listener("requestfailed", self._on_request_failed)
            self.page.remove_listener("console", self._on_console)
            self.page.remove_listener("pageerror", self._on_page_error)
        except Exception:
            pass
        self._started = False

    # ── Event handlers ──

    def _on_request(self, request):
        key = f"{request.method}:{request.url}"
        body = None
        try:
            body = request.post_data
            if body and len(body) > MAX_NETWORK_BODY:
                body = body[:MAX_NETWORK_BODY] + "...[tronqué]"
        except Exception:
            pass
        self._requests[key] = CapturedRequest(
            url=request.url,
            method=request.method,
            resource_type=request.resource_type,
            timestamp=time.time(),
            request_body=body,
        )

    def _on_response(self, response):
        key = f"{response.request.method}:{response.request.url}"
        entry = self._requests.get(key)
        if entry:
            entry.status = response.status
            entry.status_text = response.status_text
            entry.duration_ms = (time.time() - entry.timestamp) * 1000
            # Body seulement pour les erreurs (économie mémoire)
            if response.status >= 400:
                try:
                    body = response.text()
                    if len(body) > MAX_NETWORK_BODY:
                        body = body[:MAX_NETWORK_BODY] + "...[tronqué]"
                    entry.response_body = body
                except Exception:
                    pass

    def _on_request_failed(self, request):
        key = f"{request.method}:{request.url}"
        entry = self._requests.get(key)
        if entry:
            entry.failure = request.failure
        else:
            self._requests[key] = CapturedRequest(
                url=request.url,
                method=request.method,
                resource_type=request.resource_type,
                timestamp=time.time(),
                failure=request.failure,
            )

    def _on_console(self, msg):
        if len(self._console) < MAX_CONSOLE_ENTRIES:
            self._console.append(CapturedConsole(
                type=msg.type,
                text=msg.text,
                url=msg.location.get("url", "") if hasattr(msg, "location") else "",
                timestamp=time.time(),
            ))

    def _on_page_error(self, error):
        self._page_errors.append(str(error))

    # ── Récupération des données ──

    def get_dom_log(self) -> list:
        """Récupère le log DOM depuis localStorage (injecté par le listener JS)"""
        try:
            raw = self.page.evaluate(
                "JSON.parse(localStorage.getItem('__qaLocatorLog') || '[]')"
            )
            return dedup_log(raw)
        except Exception:
            return []

    def get_network_log(self) -> list[dict]:
        """Requêtes réseau filtrées (exclut les assets statiques)"""
        skip_types = {"image", "stylesheet", "font", "media"}
        result = []
        for req in self._requests.values():
            if req.resource_type in skip_types:
                continue
            result.append(asdict(req))
        return sorted(result, key=lambda x: x.get("timestamp", 0))

    def get_failed_requests(self) -> list[dict]:
        """Uniquement les requêtes en erreur (4xx/5xx ou failure)"""
        return [asdict(r) for r in self._requests.values() if r.failed]

    def get_console_errors(self) -> list[dict]:
        """Erreurs console uniquement"""
        return [asdict(c) for c in self._console if c.type == "error"]

    def take_screenshot(self) -> Optional[str]:
        """Screenshot base64 PNG de l'état courant"""
        try:
            buf = self.page.screenshot(type="png", full_page=False)
            return base64.b64encode(buf).decode("utf-8")
        except Exception:
            return None

    # ── Construction du contexte d'échec ──

    def build_failure_context(self, error_message: str, error_tb: str = "",
                               test_file: str = "") -> FailureContext:
        """Bundle TOUT le contexte nécessaire au diagnostic — en UN appel"""
        # Lire le source du test
        test_source = ""
        if test_file and Path(test_file).exists():
            try:
                test_source = Path(test_file).read_text(encoding="utf-8")
            except Exception:
                pass

        ctx = FailureContext(
            test_file=test_file,
            test_source=test_source,
            error_message=error_message,
            error_traceback=error_tb,
            dom_actions=self.get_dom_log()[:MAX_DOM_ACTIONS],
            network_log=self.get_network_log(),
            failed_requests=self.get_failed_requests(),
            console_errors=self.get_console_errors(),
            console_warnings=[asdict(c) for c in self._console if c.type == "warning"],
            page_url=self.page.url if self.page else "",
            timestamp=datetime.now().isoformat(),
        )

        if INCLUDE_SCREENSHOT:
            ctx.screenshot_b64 = self.take_screenshot()

        return ctx

    # ── Diagnostic IA ──

    def diagnose(self, error_message: str, test_file: str = "",
                  error_tb: str = "") -> dict:
        """Point d'entrée principal : diagnostic complet d'un échec de test"""
        ctx = self.build_failure_context(error_message, error_tb, test_file)
        diagnosis = ai_diagnose(ctx)

        # Sauvegarder le rapport
        REPORT_DIR.mkdir(parents=True, exist_ok=True)
        test_name = Path(test_file).stem if test_file else "unknown"
        ts = datetime.now().strftime("%Y%m%d_%H%M%S")
        report_file = REPORT_DIR / f"diag_{test_name}_{ts}.json"
        jira_file = None

        report = {
            "diagnosis": diagnosis,
            "context": {
                "test_file": ctx.test_file,
                "error_message": ctx.error_message,
                "page_url": ctx.page_url,
                "dom_actions_count": len(ctx.dom_actions),
                "network_requests_count": len(ctx.network_log),
                "failed_requests_count": len(ctx.failed_requests),
                "console_errors_count": len(ctx.console_errors),
                "timestamp": ctx.timestamp,
            },
            "dom_actions": ctx.dom_actions,
            "failed_requests": ctx.failed_requests,
        }

        with open(report_file, "w", encoding="utf-8") as f:
            json.dump(report, f, indent=2, ensure_ascii=False)

        # ── Affichage diagnostic ──
        category = diagnosis.get('category', 'N/A')
        conf = diagnosis.get('confidence', 0)

        CATEGORY_ICONS = {
            "wrong_selector": "🎯", "missing_step": "⏭️", "timing": "⏱️",
            "element_obscured": "🫣", "element_disabled": "🚫", "wrong_action": "🔀",
            "iframe_context": "🖼️", "encoding_mismatch": "🔤", "stale_reference": "👻",
            "test_data": "📊", "app_bug": "🐛", "network": "🌐",
        }
        icon = CATEGORY_ICONS.get(category, "❓")

        print(f"\n{'='*60}")
        print(f"  🔍 {_t('QA AUTOPILOT — DIAGNOSTIC')}")
        print(f"{'='*60}")
        print(f"  {_t('Test')}    : {ctx.test_file}")
        print(f"  {_t('Erreur')}  : {ctx.error_message[:120]}")
        print(f"  {_t('Page')}    : {ctx.page_url}")
        print(f"  {_t('Cause')}   : {diagnosis.get('root_cause', 'N/A')}")
        print(f"  {_t('Type')}    : {icon} {category}")
        print(f"  {_t('Confiance')}: {'🟢' if conf > 0.7 else '🟡' if conf > 0.4 else '🔴'} {conf:.0%}")

        # ── Traceback filtré — ligne de test + erreur finale uniquement ──
        if ctx.error_traceback:
            tb_lines = ctx.error_traceback.strip().split("\n")
            test_line = None
            error_line = None
            code_line = None
            for i, line in enumerate(tb_lines):
                # Ligne du fichier de test
                if ctx.test_file and ctx.test_file in line and "File" in line:
                    test_line = line.strip()
                    if i + 1 < len(tb_lines):
                        code_line = tb_lines[i + 1].strip()
                # Ligne d'erreur — contient "Error:" ou "AssertionError" etc.
                if ("Error:" in line or "AssertionError:" in line or "Exception:" in line) and not line.startswith(" " * 8):
                    error_line = line.strip()
            # Fallback : dernière ligne non vide
            if not error_line:
                for line in reversed(tb_lines):
                    if line.strip():
                        error_line = line.strip()
                        break
            if test_line or error_line:
                print(f"\n  📋 {_t('Traceback')} :")
                if test_line:
                    print(f"     📍 {test_line}")
                if code_line:
                    print(f"        → {code_line}")
                if error_line:
                    print(f"     ❌ {error_line}")

        # ── Fix suggéré (si ce n'est pas un app_bug) ──
        if category != "app_bug":
            fix = diagnosis.get("suggested_fix", {})
            if fix.get("description"):
                print(f"\n  💡 {_t('FIX SUGGÉRÉ')} :")
                print(f"     {fix['description']}")
            if fix.get("code"):
                print(f"\n  📝 {_t('CODE')} :")
                for line in fix["code"].split("\n"):
                    print(f"     {line}")

        # ── Sélecteurs à risque ──
        risky = diagnosis.get("selectors_at_risk", [])
        if risky:
            print(f"\n  ⚠️ {_t('SÉLECTEURS FRAGILES')} :")
            for s in risky:
                print(f"     • {s}")

        # ── Ticket Jira (si app_bug) ──
        jira = diagnosis.get("jira_ticket")
        if jira and isinstance(jira, dict):
            jira_file = REPORT_DIR / f"jira_{test_name}_{ts}.md"
            jira_md = _build_jira_markdown(jira, ctx)
            with open(jira_file, "w", encoding="utf-8") as f:
                f.write(jira_md)

            print(f"\n  🐛 {_t('APPLICATION BUG DÉTECTÉ')} — {_t('Ne pas modifier le test')} !")
            print(f"     {_t('Titre')}  : {jira.get('title', 'N/A')}")
            print(f"     {_t('Priorité')} : {jira.get('priority', 'N/A')}")
            print(f"     {_t('Steps')}  : {len(jira.get('steps_to_reproduce', []))} {_t('étapes')}")
            print(f"\n  📋 {_t('Ticket Jira')} → {jira_file}")

        print(f"\n  📁 {_t('Rapport')} → {report_file}")
        print(f"{'='*60}\n")

        return diagnosis


# ============================================================
# GÉNÉRATEUR JIRA MARKDOWN
# ============================================================

def _build_jira_markdown(jira: dict, ctx: FailureContext) -> str:
    """Génère un ticket Jira prêt à copier-coller en Markdown"""
    steps = jira.get("steps_to_reproduce", [])
    steps_str = "\n".join(f"{s}" for s in steps) if steps else "N/A"

    return f"""# 🔴 APPLICATION BUG DETECTED — This is NOT a test issue

## {jira.get('title', '[BUG] Titre manquant')}

**Priorité :** {jira.get('priority', 'Major')}
**Environnement :** {jira.get('environment', 'Playwright + Chromium headless')}
**Test :** `{ctx.test_file}`
**Date :** {ctx.timestamp}

---

### Description

{jira.get('description', 'N/A')}

### Steps to Reproduce

{steps_str}

### Expected

{jira.get('expected', 'N/A')}

### Actual

{jira.get('actual', 'N/A')}

### Technical Details

{jira.get('technical_details', 'N/A')}

- **URL au crash :** `{ctx.page_url}`
- **Requêtes en échec :** {len(ctx.failed_requests)}
- **Erreurs console :** {len(ctx.console_errors)}

---

> ⚠️ **Ne pas modifier le test.** Le test est correct — il a détecté un vrai bug applicatif.
> Laisser le test en échec pour que la CI continue à signaler le problème.

---
*Généré automatiquement par QA Autopilot — JMer Consulting*
"""


# ============================================================
# DÉDUPLICATION (logique de Julien, éprouvée)
# ============================================================

def dedup_log(raw_log: list) -> list:
    """Déduplique : clics/scrolls gardés, inputs = dernière valeur par champ"""
    clean = []
    last_input = {}
    for entry in raw_log:
        if entry.get("action") in ("click", "scroll"):
            clean.append(entry)
        elif entry.get("action") == "input":
            key = (entry.get("selector", {}).get("value", ""), entry.get("url", ""))
            if key in last_input:
                clean[last_input[key]] = entry
            else:
                last_input[key] = len(clean)
                clean.append(entry)
    return clean


# ============================================================
# DIAGNOSTIC IA — UN prompt, UN appel, TOUT le contexte
# ============================================================

def ai_diagnose(ctx: FailureContext) -> dict:
    """Appel IA unique avec tout le contexte de l'échec — prompt v2 enrichi"""
    try:
        from openai import OpenAI
    except ImportError:
        return {"error": "pip install openai", "root_cause": "OpenAI non installé",
                "category": "setup", "confidence": 0}

    # Construire le contexte textuel
    dom_summary = ""
    if ctx.dom_actions:
        steps = []
        for i, a in enumerate(ctx.dom_actions[:30]):
            sel = a.get("selector", {}).get("value", "?")
            unique = "✅" if a.get("selector", {}).get("unique") else "⚠️"
            if a["action"] == "click":
                txt = a.get("text", "")[:40]
                steps.append(f"  {i+1}. CLICK {unique} {sel} (texte: '{txt}')")
            elif a["action"] == "input":
                steps.append(f"  {i+1}. INPUT {unique} {sel} = '{a.get('value', '')}'")
            elif a["action"] == "scroll":
                steps.append(f"  {i+1}. SCROLL {a.get('direction', '?')} {a.get('deltaY', 0)}px")
        dom_summary = "\n".join(steps)

    network_summary = ""
    if ctx.failed_requests:
        lines = []
        for r in ctx.failed_requests[:10]:
            lines.append(f"  ❌ {r.get('method', '?')} {r.get('url', '?')[:80]} → {r.get('status', '?')} {r.get('failure', '')}")
            if r.get("response_body"):
                lines.append(f"     Body: {r['response_body'][:200]}")
        network_summary = "\n".join(lines)

    console_summary = ""
    if ctx.console_errors:
        lines = [f"  🔴 {c.get('text', '')[:120]}" for c in ctx.console_errors[:10]]
        console_summary = "\n".join(lines)

    prompt = f"""Tu es un QA Engineer senior avec 20 ans d'expérience Playwright. Un test a échoué.
Diagnostique la cause racine et propose un fix concret.

═══════════════════════════════════════════════════════════════
CODE DU TEST
═══════════════════════════════════════════════════════════════
Fichier : {ctx.test_file}
```python
{ctx.test_source[:3000] if ctx.test_source else '[source non disponible]'}
```

═══════════════════════════════════════════════════════════════
ERREUR COMPLÈTE (message + call log Playwright)
═══════════════════════════════════════════════════════════════
{ctx.error_message}
{ctx.error_traceback[:1500] if ctx.error_traceback else ''}

═══════════════════════════════════════════════════════════════
URL AU MOMENT DU CRASH
═══════════════════════════════════════════════════════════════
{ctx.page_url}

═══════════════════════════════════════════════════════════════
ACTIONS DOM CAPTURÉES ({len(ctx.dom_actions)} total)
═══════════════════════════════════════════════════════════════
{dom_summary if dom_summary else '[aucune action capturée]'}

═══════════════════════════════════════════════════════════════
REQUÊTES RÉSEAU EN ÉCHEC ({len(ctx.failed_requests)})
═══════════════════════════════════════════════════════════════
{network_summary if network_summary else '[aucune requête en échec]'}

═══════════════════════════════════════════════════════════════
ERREURS CONSOLE ({len(ctx.console_errors)})
═══════════════════════════════════════════════════════════════
{console_summary if console_summary else '[aucune erreur console]'}

═══════════════════════════════════════════════════════════════
GUIDE DE DIAGNOSTIC — LIS ATTENTIVEMENT AVANT DE RÉPONDRE
═══════════════════════════════════════════════════════════════

ÉTAPE 1 — Analyse le CALL LOG Playwright mot par mot.
Ces messages sont la source de vérité n°1. Indices critiques :
  • "element is not visible"         → overlay/bandeau/popup qui recouvre OU iframe
  • "element is not enabled"         → élément DISABLED (attribut disabled/aria-disabled)
  • "element is not stable"          → élément en cours d'animation/transition
  • "locator resolved to N elements" → sélecteur trop large (strict mode violation)
  • "waiting for locator"            → timeout, élément jamais apparu
  • "intercept" ou "click intercepted" → un élément recouvre la cible (cookie banner, modal, overlay)
  • "frame was detached"             → iframe rechargé/supprimé pendant l'action
  • "about:blank"                    → navigation manquante (pas de goto)

ÉTAPE 2 — Compare le ATTENDU vs RÉEL dans l'erreur.
  • Si c'est une assertion to_have_text :
    - Compare caractère par caractère les accents, cédilles, ligatures (é≠e, ç≠c, œ≠oe)
    - Vérifie si le test utilise une regex → la regex matche-t-elle le texte réel ?
    - "Actual value: X" te dit EXACTEMENT ce que la page contient
  • Si c'est une assertion to_have_url :
    - Vérifie les redirections 301/302 (URL finale ≠ URL demandée)
  • Si c'est une assertion to_have_title :
    - Compare le titre attendu avec "Actual value" → souvent copier-coller d'un autre test

ÉTAPE 3 — Détecte les erreurs logiques dans le CODE du test.
  • L'erreur est sur une assertion expect() ET non sur un click/fill ?
    → Cherche d'abord wrong_action ou stale_reference AVANT element_obscured.
  • Le code contient button.click() mais l'événement attendu est déclenché par ondblclick ?
    → wrong_action (click vs dblclick). L'action passe SANS erreur, c'est l'assertion qui fail.
  • Le code fait fill("valeur1") puis fill("valeur2") puis utilise un locator capturé avant ?
    → stale_reference
  • page.locator("tag") utilisé directement alors qu'il y a un frame_locator ?
    → iframe_context
  • Interaction sans goto() préalable → page about:blank
  
═══════════════════════════════════════════════════════════════
CATÉGORIES DE DIAGNOSTIC (choisis UNE seule)
═══════════════════════════════════════════════════════════════

"wrong_selector"      — Sélecteur cassé, inexistant, ou trop large
                        Indice : "locator resolved to N elements", élément introuvable

"missing_step"        — Le test oublie une interaction requise
                        Indice : cookie banner non fermé, goto manquant, dropdown non sélectionné

"timing"              — Race condition, élément pas encore prêt
                        Indice : fill + click immédiat, stale reference après AJAX, "waiting for"

"element_obscured"    — L'élément existe mais est recouvert par un overlay/modal/bannière
                        Indice : "not visible", "click intercepted", bandeau cookies, popup

"element_disabled"    — L'élément est trouvé mais désactivé (disabled/aria-disabled)
                        Indice : "element is not enabled", "disabled" dans le call log

"wrong_action"        — Mauvaise méthode Playwright (click vs dblclick, fill vs type, etc.)
                        Indice PRIORITAIRE : l'erreur est sur expect() PAS sur click/fill.
                        RÈGLE : si TimeoutError sur une assertion après une action réussie
                      → vérifie wrong_action EN PREMIER avant element_obscured.

"iframe_context"      — Élément cherché dans le mauvais frame (main vs iframe)
                        Indice : "resolved to N elements" dont certains non visibles, frame_locator absent

"encoding_mismatch"   — Problème Unicode/accents/regex dans les assertions
                        Indice : texte attendu vs réel diffère par accents (e vs é, Zidane vs Zidane)

"stale_reference"     — Locator capturé avant un changement DOM, utilisé après
                        Indice : assertion fail avec mauvaise valeur alors que le sélecteur est correct

"test_data"           — Assertion avec mauvaise valeur attendue (copier-coller, données périmées)
                        Indice : to_have_title("Google") sur une page Wikipedia

"app_bug"             — L'application est cassée, PAS le test
                        Indice : erreurs 5xx, exceptions console, UI en état d'erreur
                        IMPORTANT : si c'est un app_bug, ne propose PAS de modifier le test.
                        Génère plutôt un rapport de bug (voir structure jira_ticket ci-dessous).

"network"             — Requêtes réseau en échec (4xx/5xx, timeout, CORS)
                        Indice : failed_requests non vide, fetch/XHR en erreur

═══════════════════════════════════════════════════════════════
CONSIGNES DE RÉPONSE
═══════════════════════════════════════════════════════════════

1. Identifie UNE SEULE cause racine (pas 3 hypothèses)
2. Choisis la catégorie la plus spécifique (ex: "element_disabled" plutôt que "wrong_selector")
3. Propose un fix CONCRET avec le CODE EXACT à modifier
4. Si c'est un app_bug → remplis le champ jira_ticket, ne modifie PAS le test
5. Donne ton niveau de confiance (0.0 à 1.0)
6. Liste les sélecteurs fragiles détectés

Réponds UNIQUEMENT en JSON valide (pas de markdown, pas de commentaires) :
{{
  "root_cause": "explication concise de la cause racine",
  "category": "wrong_selector|missing_step|timing|element_obscured|element_disabled|wrong_action|iframe_context|encoding_mismatch|stale_reference|test_data|app_bug|network",
  "confidence": 0.85,
  "diagnosis": "explication détaillée en 2-3 phrases",
  "suggested_fix": {{
    "file": "{ctx.test_file}",
    "description": "ce qu'il faut changer",
    "code": "le code corrigé (snippet, pas tout le fichier)"
  }},
  "selectors_at_risk": ["liste des sélecteurs non-uniques ou fragiles détectés"],
  "jira_ticket": null
}}

Si category == "app_bug", remplace jira_ticket null par :
{{
  "jira_ticket": {{
    "title": "[BUG] titre concis du bug",
    "priority": "Critical|Major|Minor",
    "description": "description user-facing du problème",
    "steps_to_reproduce": ["1. Naviguer vers ...", "2. Cliquer sur ...", "3. Observer que ..."],
    "expected": "ce qui devrait se passer",
    "actual": "ce qui se passe réellement",
    "technical_details": "endpoint en erreur, status, console errors, etc.",
    "environment": "Playwright + Chromium headless"
  }}
}}"""

    try:
        # Support multi-provider : OpenAI, DeepSeek, Ollama, ou tout provider compatible API OpenAI
        # BASE_URL=https://api.deepseek.com  API_KEY=sk-...  QA_MODEL=deepseek-chat
        # BASE_URL=http://localhost:11434/v1  API_KEY=ollama  QA_MODEL=llama3
        # Par défaut : OpenAI natif via OPENAI_API_KEY
        base_url = os.getenv("BASE_URL", None)
        api_key  = os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY")
        client = OpenAI(base_url=base_url, api_key=api_key)
        print(f"\n  🤖 {_t('Analyse IA en cours')}...")

        for attempt in range(3):
            response = client.chat.completions.create(
                model=LLM_MODEL,
                messages=[
                    {"role": "system", "content": (
                        "You are a Playwright QA automation expert. "
                        "Respond ONLY in valid JSON. No markdown, no backticks, no comments."
                        + (f" CRITICAL LANGUAGE INSTRUCTION: You MUST write ALL text values in the JSON response (root_cause, diagnosis, description, code comments, suggested_fix fields) exclusively in {get_lang_name(get_qa_lang())} language. Do NOT use any other language under any circumstance. This is mandatory and non-negotiable." if get_qa_lang() else "")
                    )},
                    {"role": "user", "content": prompt},
                ],
                temperature=0,
                response_format={"type": "json_object"},  # ← ajoute ça
            )
            raw = response.choices[0].message.content.strip()
            if raw.startswith("```"):
                raw = raw.split("\n", 1)[1]
            if raw.endswith("```"):
                raw = raw.rsplit("```", 1)[0]
            try:
                return json.loads(raw.strip())
            except json.JSONDecodeError:
                if attempt < 2:
                    print(f"  ⚠️ JSON invalide (tentative {attempt+1}/3), retry...")
                    continue
                return {"root_cause": "Réponse IA invalide après 3 tentatives",
                        "category": "unknown", "confidence": 0, "raw_response": raw}
    except Exception as e:
        return {"root_cause": f"Erreur appel IA: {e}", "category": "error",
                "confidence": 0}


# ============================================================
# PLUGIN PYTEST — Drop-in, zéro config
# ============================================================

def pytest_addoption(parser):
    """Ajoute le flag --qa-autopilot à pytest"""
    parser.addoption(
        "--qa-autopilot",
        action="store_true",
        default=False,
        help="Active le diagnostic IA automatique sur les tests Playwright en échec",
    )
    parser.addoption(
        "--qa-lang",
        action="store",
        default=None,
        help="Langue des diagnostics IA : fr_FR, zh_CN, en_US, off, auto (défaut: auto = locale système)",
    )


def pytest_configure(config):
    """Enregistre le marker qa_autopilot + applique --qa-lang si fourni"""
    config.addinivalue_line(
        "markers", "qa_autopilot: active le diagnostic IA sur ce test"
    )
    lang_arg = config.getoption("--qa-lang", default=None, skip=True)
    if lang_arg is not None:
        os.environ["QA_LANG"] = lang_arg


try:
    import pytest

    @pytest.fixture(autouse=True)
    def _qa_autopilot_fixture(request):
        """Fixture auto : intercepte les pages Playwright si --qa-autopilot actif"""
        # Vérifier que le flag est actif
        if not request.config.getoption("--qa-autopilot", default=False):
            yield
            return

        # Vérifier que le test utilise une page Playwright
        if "page" not in request.fixturenames:
            yield
            return

        # Récupérer la page et brancher l'intercepteur
        page = request.getfixturevalue("page")
        interceptor = QAInterceptor(page)
        interceptor.start()
        request.node._qa_interceptor = interceptor

        yield

        interceptor.stop()

    @pytest.hookimpl(hookwrapper=True)
    def pytest_runtest_makereport(item, call):
        """Hook pytest : déclenche le diagnostic IA si le test échoue"""
        outcome = yield
        report = outcome.get_result()

        if report.when == "call" and report.failed:
            interceptor = getattr(item, "_qa_interceptor", None)
            if interceptor:
                error_msg = str(call.excinfo.value) if call.excinfo else "Unknown"
                error_tb = "".join(traceback.format_exception(
                    type(call.excinfo.value), call.excinfo.value,
                    call.excinfo.value.__traceback__
                )) if call.excinfo else ""
                test_file = str(item.fspath) if item.fspath else ""

                item._qa_diagnosis = interceptor.diagnose(
                    error_message=error_msg,
                    test_file=test_file,
                    error_tb=error_tb,
                )

    @pytest.hookimpl(trylast=True)
    def pytest_sessionfinish(session, exitstatus):
        reports = []
        for item in session.items:
            diag = getattr(item, "_qa_diagnosis", None)
            if diag:
                reports.append({
                    "test": item.name,
                    "category": diag.get("category"),
                    "confidence": diag.get("confidence"),
                    "root_cause": diag.get("root_cause"),
                    "suggested_fix": diag.get("suggested_fix", {}).get("description"),
                })
        if reports:
            REPORT_DIR.mkdir(parents=True, exist_ok=True)
            ts = datetime.now().strftime("%Y%m%d_%H%M%S")
            summary_file = REPORT_DIR / f"summary_{ts}.json"
            with open(summary_file, "w", encoding="utf-8") as f:
                json.dump(reports, f, indent=2, ensure_ascii=False)
            print(f"\n📊 Rapport consolidé → {summary_file}")
            
except ImportError:
    pass  # pytest pas installé → mode import direct uniquement



def build_pytest_command(user_args: list[str], python_executable: str) -> list[str]:
    """Construit la commande pytest utilisée par le mode CLI."""
    return [
        python_executable, "-m", "pytest",
        "--qa-autopilot",
        "-v",
        "--tb=short",
        *user_args,
    ]

# ============================================================
# CLI — python qa_autopilot.py tests/test_whatever.py
# ============================================================

def main():
    import sys
    import subprocess

    if len(sys.argv) < 2:
        print("""
  🚀 QA Autopilot — L'anti usine à gaz
  ======================================

  Usage :
    python qa_autopilot.py tests/test_checkout.py          # un fichier
    python qa_autopilot.py tests/                           # tout un dossier
    python qa_autopilot.py tests/test_login.py::test_auth   # un test précis
    python qa_autopilot.py tests/ -k "checkout"             # par keyword
    python qa_autopilot.py tests/ --headed                  # avec navigateur visible

  Ou en plugin pytest direct :
    pytest tests/ --qa-autopilot

  Config (variables d'env) :
    OPENAI_API_KEY    → clé API OpenAI (obligatoire)
    QA_MODEL          → modèle IA (défaut: gpt-4.1-mini)
    QA_SCREENSHOT     → 1 pour inclure les screenshots dans le prompt
    QA_REPORT_DIR     → dossier des rapports (défaut: qa-reports/)
        """)
        sys.exit(0)

    # Construire la commande pytest avec notre plugin
    cmd = build_pytest_command(sys.argv[1:], sys.executable)

    # Ajouter le répertoire courant au PYTHONPATH pour l'import
    env = os.environ.copy()
    parent = str(Path(__file__).parent)
    env["PYTHONPATH"] = f"{parent}:{env.get('PYTHONPATH', '')}"

    print(f"  🚀 Lancement : {' '.join(cmd)}")
    result = subprocess.run(cmd, env=env)
    sys.exit(result.returncode)


if __name__ == "__main__":
    main()