local CALC_SYMPY = {}
local M = require("texecole-math")

-- Attente sans fork : le worker est interroge par sondage. « os.execute("sleep")
-- » forkait un shell à chaque tour ; socket.sleep (fourni par LuaTeX) attend
-- sans creer de processus. Repli sur l'ancien procede si socket est absent.
local _sleep
do
  local ok, socket = pcall(require, "socket")
  if ok and type(socket) == "table" and socket.sleep then
    _sleep = function(s) socket.sleep(s) end
  else
    _sleep = function(s) os.execute("sleep " .. s) end
  end
end

local memcache = {}
local CACHE_DIR = "_texecole-cache"
local cache_dir_ready = nil

local PRE = [==[
from sympy import *
from sympy.parsing.sympy_parser import (parse_expr,
    standard_transformations, implicit_multiplication_application,
    convert_xor)
_T = standard_transformations + (implicit_multiplication_application,
    convert_xor)
def lire(s, v):
    return parse_expr(s, transformations=_T, local_dict={str(v): v})
]==]

CALC_SYMPY.PRE = PRE

local function to_sympy(expr)
  return (expr:gsub("%^", "**"))
end

local function to_moteur(expr)
  return (expr:gsub("%*%*", "^"):gsub("%f[%w]oo%f[%W]", "inf"))
end

CALC_SYMPY.to_sympy, CALC_SYMPY.to_moteur = to_sympy, to_moteur

local function hash(s)
  local h1, h2 = 5381, 52711
  for i = 1, #s do
    local b = s:byte(i)
    h1 = (h1 * 33 + b) % 4294967296
    h2 = (h2 * 31 + b) % 4294967296
  end
  return string.format("%08x%08x-%d", h1, h2, #s)
end

local function cache_path(script)
  if cache_dir_ready == nil then
    local ok, lfs = pcall(require, "lfs")
    if ok and lfs then
      local attr = lfs.attributes(CACHE_DIR)
      if attr and attr.mode == "directory" then
        cache_dir_ready = true
      else
        cache_dir_ready = lfs.mkdir(CACHE_DIR) and true or false
      end
    else
      cache_dir_ready = false
    end
  end
  if not cache_dir_ready then return nil end
  return CACHE_DIR .. "/" .. hash(script) .. ".txt"
end

local function cache_read(script)
  if memcache[script] then return memcache[script] end
  local p = cache_path(script)
  if not p then return nil end
  local f = io.open(p, "r")
  if not f then return nil end
  local out = f:read("*a")
  f:close()
  memcache[script] = out
  return out
end

local function cache_write(script, out)
  memcache[script] = out
  local p = cache_path(script)
  if not p then return end
  local f = io.open(p, "w")
  if f then f:write(out); f:close() end
end

local SERVEUR = [==[
import sys, os, time, io, contextlib, traceback, shutil
D = sys.argv[1]
dernier = time.time()
bat = 0.0
while True:
    t = time.time()
    if t - bat >= 0.5:
        with open(os.path.join(D, "vie.tmp"), "w") as f:
            f.write(str(t))
        os.replace(os.path.join(D, "vie.tmp"), os.path.join(D, "vie"))
        bat = t
    reqs = sorted(int(f[:-4]) for f in os.listdir(D) if f.endswith(".req"))
    if not reqs:
        if t - dernier > 120:
            break
        time.sleep(0.02)
        continue
    for k in reqs:
        base = os.path.join(D, str(k))
        try:
            os.remove(base + ".req")
        except OSError:
            continue
        buf = io.StringIO()
        try:
            src = open(base + ".py").read()
            with contextlib.redirect_stdout(buf):
                exec(src, {})
            out = buf.getvalue()
        except SystemExit:
            out = buf.getvalue()
        except Exception:
            out = buf.getvalue() + "\nTraceback\n" + traceback.format_exc()
        with open(base + ".tmp", "w") as f:
            f.write(out)
        os.replace(base + ".tmp", base + ".out")
        with open(base + ".done", "w") as f:
            f.write("1")
        dernier = time.time()
shutil.rmtree(D, ignore_errors=True)
]==]

local WORKER
local POINTEUR = CACHE_DIR .. "/worker-actif"
local LFS
do
  local ok, lfs = pcall(require, "lfs")
  if ok then LFS = lfs end
end

return function(sl)
  if not sl then return CALC_SYMPY end

  -- Le serveur écrit son battement de cœur dans « vie » toutes les 0,5 s.
  -- Un fichier plus vieux que 3 s signale un serveur mort ou mourant.
  local function worker_vivant(dir)
    local attr = LFS.attributes(dir .. "/vie")
    return attr and (os.time() - attr.modification) <= 3
  end

  local function demarrer_worker(python)
    if WORKER ~= nil then return WORKER end
    WORKER = false
    if not cache_path("sonde") then return false end
    if not LFS then return false end
    -- Réutiliser le serveur d'une compilation précédente s'il vit encore :
    -- SymPy y est déjà importé, le premier calcul part sans délai. Le
    -- fichier pointeur, écrit au lancement, mène à son dossier.
    local pf = io.open(POINTEUR, "r")
    if pf then
      local ancien = pf:read("*l")
      pf:close()
      if ancien and LFS.attributes(ancien) and worker_vivant(ancien) then
        WORKER = { dir = ancien, n = 0, reprise = true }
        return WORKER
      end
    end
    local dir = CACHE_DIR .. "/worker-" .. tostring(os.time() % 100000)
      .. "-" .. tostring(math.floor((os.clock() * 1e6) % 100000))
    if not LFS.mkdir(dir) then return false end
    local f = io.open(dir .. "/serveur.py", "w")
    if not f then return false end
    f:write(SERVEUR)
    f:close()
    local lance = os.execute(python .. " " .. dir .. "/serveur.py " .. dir
      .. " > " .. dir .. "/serveur.log 2>&1 &")
    if not lance then return false end
    local pf2 = io.open(POINTEUR, "w")
    if pf2 then pf2:write(dir); pf2:close() end
    WORKER = { dir = dir, n = 0 }
    return WORKER
  end

  -- Soumettre sans attendre : le script est déposé auprès du serveur,
  -- qui le traitera dans l'ordre. Renvoie le chemin de base de la
  -- requête, à passer plus tard à worker_attendre. Le numéro de requête
  -- est réservé par « mkdir » (atomique) : deux compilations peuvent
  -- ainsi partager le même serveur sans se marcher dessus.
  local function worker_soumettre(python, script)
    local w = demarrer_worker(python)
    if not w then return nil end
    local base
    for _ = 1, 100000 do
      w.n = w.n + 1
      if LFS.mkdir(w.dir .. "/" .. w.n .. ".pris") then
        base = w.dir .. "/" .. w.n
        break
      end
    end
    if not base then WORKER = false; return nil end
    local f = io.open(base .. ".py", "w")
    if not f then WORKER = false; return nil end
    f:write(script)
    f:close()
    local g = io.open(base .. ".req", "w")
    if not g then WORKER = false; return nil end
    g:write("1")
    g:close()
    return base, w.n
  end

  local function worker_attendre(base, premiere)
    local attente = 0
    local pas = 0.02
    local limite = premiere and 30 or 90
    while attente < limite do
      local d = io.open(base .. ".done", "r")
      if d then
        d:close()
        local h = io.open(base .. ".out", "r")
        if not h then break end
        local out = h:read("*a")
        h:close()
        os.remove(base .. ".py"); os.remove(base .. ".out")
        os.remove(base .. ".done")
        if LFS then pcall(LFS.rmdir, base .. ".pris") end
        return out
      end
      _sleep(pas)
      attente = attente + pas
      if pas < 0.1 then pas = pas + 0.02 end
    end
    -- Serveur muet. S'il s'agissait d'un serveur repris d'une compilation
    -- précédente (mort entre la sonde et la requête), oublier le pointeur
    -- et laisser « nil » : le prochain appel lancera un serveur neuf. Si
    -- c'est un serveur que cette compilation vient de lancer, « false »
    -- bascule définitivement sur le procédé fichier-par-fichier, comme
    -- avant — jamais plus d'une relance.
    local reessayer = WORKER and WORKER.reprise
    os.remove(POINTEUR)
    WORKER = reessayer and nil or false
    return nil
  end

  local function worker_run(python, script)
    local base, n = worker_soumettre(python, script)
    if not base then return nil end
    return worker_attendre(base, n == 1)
  end

  -- Requêtes soumises par le balayage précoce (texecole-prefetch) et pas
  -- encore collectées : script -> chemin de base auprès du serveur.
  local PENDING = {}
  local PREFETCH = false

  -- L'option de classe « python= » n'existe plus : la commande Python se
  -- trouve toute seule, une fois par compilation, en essayant dans l'ordre
  -- python3 (Linux, macOS), python puis py (Windows). Le résultat est mis
  -- en cache pour toute la durée de la compilation.
  local PYTHON_TROUVE
  local function python_cmd()
    if PYTHON_TROUVE then return PYTHON_TROUVE end
    for _, cand in ipairs { "python3", "python", "py" } do
      local h = io.popen(cand .. " --version 2>&1")
      if h then
        local sortie = h:read("*a") or ""
        h:close()
        if sortie:find("Python%s+3") then
          PYTHON_TROUVE = cand
          return cand
        end
      end
    end
    PYTHON_TROUVE = "python3"
    return PYTHON_TROUVE
  end
  CALC_SYMPY.python_cmd = python_cmd

  local function run(script)
    local cached = cache_read(script)
    if cached then return cached end
    local python = python_cmd()

    -- Mode préchargement (balayage précoce) : soumettre sans attendre.
    -- Le serveur Python calcule pendant que TeX charge son préambule ;
    -- le vrai appel, à la transpilation, collectera le résultat.
    if PREFETCH then
      if not PENDING[script] then
        PENDING[script] = worker_soumettre(python, script)
      end
      return nil, "texecole : préchargement en cours"
    end

    local via
    local attendu = PENDING[script]
    if attendu then
      PENDING[script] = nil
      via = worker_attendre(attendu, false)
    end
    if via == nil then
      via = worker_run(python, script)
    end
    if via ~= nil then
      local out = via:gsub("%s+$", "")
      if out:find("Traceback") or out:find("No module named") then
        if out:find("No module named") then
          return nil, "texecole : SymPy n'est pas installé pour « " .. python
            .. " ». Installez-le : " .. python .. " -m pip install sympy — "
            .. "sans SymPy, seuls les calculs automatiques manquent, le "
            .. "reste de texecole fonctionne intégralement."
        end
        return nil, "texecole : le calcul SymPy a échoué.\n"
          .. "Sortie de Python :\n" .. out
      end
      cache_write(script, out)
      return out
    end
    local base = os.tmpname()
    local inpath, outpath = base .. ".py", base .. ".out"
    local f = io.open(inpath, "w")
    if not f then
      return nil, "texecole : impossible d'écrire le fichier temporaire "
        .. "du calcul (droits du dossier ?)."
    end
    f:write(script)
    f:close()
    local cmd = python .. " " .. inpath .. " > " .. outpath .. " 2>&1"
    local ok = os.execute(cmd)
    local g = io.open(outpath, "r")
    local out = g and g:read("*a") or ""
    if g then g:close() end
    os.remove(inpath); os.remove(outpath); os.remove(base)
    out = out:gsub("%s+$", "")
    if (not ok) or out:find("Traceback") or out:find("command not found")
       or out:find("No module named") then
      if out:find("No module named") then
        return nil, "texecole : SymPy n'est pas installé pour « " .. python
          .. " ». Installez-le : " .. python .. " -m pip install sympy — "
          .. "sans SymPy, seuls les calculs automatiques manquent, le "
          .. "reste de texecole fonctionne intégralement."
      end
      return nil, "texecole : le calcul SymPy a échoué.\n"
        .. "Vérifiez : (1) python3 et sympy installés ; (2) la compilation "
        .. "avec --shell-escape (TeXstudio : Options > Configurer > "
        .. "Production, ajoutez --shell-escape à la commande LuaLaTeX).\n"
        .. "Sortie de Python :\n" .. out
    end
    cache_write(script, out)
    return out
  end

  CALC_SYMPY.run = run

  function CALC_SYMPY.prefetch_debut() PREFETCH = true end
  function CALC_SYMPY.prefetch_fin() PREFETCH = false end

  function CALC_SYMPY.eval(expr)
    return run(PRE
      .. "x, y, z, t, a, b, c, n = symbols('x y z t a b c n')\n"
      .. "print(" .. expr .. ")\n")
  end

  function CALC_SYMPY.variations(expr, var)
    var = var or "x"
    local script = (PRE .. [==[
%s = symbols('%s')
f = lire("%s", %s)
fp = simplify(diff(f, %s))
if fp == 0:
    print("TEXECOLE_ERREUR: fonction constante")
    raise SystemExit
sing = singularities(f, %s, domain=S.Reals)
if sing is S.EmptySet:
    poles = []
elif isinstance(sing, FiniteSet):
    poles = sorted([p for p in sing if p.is_real])
else:
    print("TEXECOLE_ERREUR: singularites non denombrables")
    raise SystemExit
zs = solveset(fp, %s, domain=S.Reals)
if zs is S.EmptySet:
    zeros = []
elif isinstance(zs, FiniteSet):
    zeros = sorted([z for z in zs if z.is_real and z not in poles])
else:
    print("TEXECOLE_ERREUR: zeros de la derivee non denombrables")
    raise SystemExit
pts = sorted(set(zeros) | set(poles))
bounds = [S.NegativeInfinity] + pts + [S.Infinity]
signs = []
for i in range(len(bounds) - 1):
    a_, b_ = bounds[i], bounds[i + 1]
    if a_ is S.NegativeInfinity and b_ is S.Infinity: m = 0
    elif a_ is S.NegativeInfinity: m = b_ - 1
    elif b_ is S.Infinity: m = a_ + 1
    else: m = (a_ + b_) / 2
    v = fp.subs(%s, m)
    if not v.is_comparable:
        v = v.evalf()
    signs.append("+" if v > 0 else "-")
def fmt(v):
    try:
        v = nsimplify(v)
    except Exception:
        pass
    return str(v)
row = [fmt(limit(f, %s, S.NegativeInfinity))]
dcells = []
for i, p in enumerate(pts):
    row.append("/" if signs[i] == "+" else "\\")
    dcells.append(signs[i])
    if p in poles:
        row.append(fmt(limit(f, %s, p, dir='-')))
        row.append("||")
        row.append(fmt(limit(f, %s, p, dir='+')))
        dcells.append("||")
    else:
        row.append(fmt(f.subs(%s, p)))
row.append("/" if signs[-1] == "+" else "\\")
row.append(fmt(limit(f, %s, S.Infinity)))
dcells.append(signs[-1])
pts_txt = ["-inf"] + [str(p) for p in pts] + ["+inf"]
print("X|" + " | ".join(pts_txt))
print("D|" + " | ".join(dcells))
print("V|" + " ".join(row))
]==]):format(var, var, expr, var, var, var, var, var, var, var, var, var, var)
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_ERREUR") then
      if out:find("fonction constante") then
        return nil, "texecole : la fonction « " .. expr .. " » est constante ; "
          .. "il n'y a pas de variations à dresser."
      end
      return nil, "texecole : le calcul automatique n'a pas trouvé un "
        .. "nombre fini de points remarquables (zéros de la dérivée, "
        .. "valeurs interdites) pour « " .. expr .. " » — donnez les "
        .. "rangées à la main (x:, dérivée:, variations:)."
    end
    local rows = {}
    for line in (out .. "\n"):gmatch("(.-)\n") do
      local k, v = line:match("^(%u)|(.*)$")
      if k then rows[k] = to_moteur(v) end
    end
    if not (rows.X and rows.D and rows.V) then
      return nil, "texecole : réponse SymPy inattendue :\n" .. out
    end
    return { x = rows.X, deriv = rows.D, var = rows.V }
  end

  -- Tableau de signes automatique d'une expression quelconque : zéros
  -- réels du numérateur, valeurs interdites (zéros du dénominateur),
  -- signe de f sur chaque intervalle. Sert de repli quand l'expression
  -- n'est pas un produit de facteurs affines.
  function CALC_SYMPY.signes(expr, var)
    var = var or "x"
    local script = (PRE .. [==[
%s = symbols('%s')
f = lire("%s", %s)
num, den = fraction(together(f))
zs = solveset(num, %s, domain=S.Reals)
ps = solveset(den, %s, domain=S.Reals)
def denombre(ens, quoi):
    if ens is S.EmptySet:
        return []
    if isinstance(ens, FiniteSet):
        return sorted([p for p in ens if p.is_real])
    print("TEXECOLE_ERREUR: " + quoi + " non denombrables")
    raise SystemExit
poles = denombre(ps, "valeurs interdites")
zeros = [z for z in denombre(zs, "zeros") if z not in poles]
pts = sorted(set(zeros) | set(poles))
bounds = [S.NegativeInfinity] + pts + [S.Infinity]
cells = []
for i in range(len(bounds) - 1):
    a_, b_ = bounds[i], bounds[i + 1]
    if a_ is S.NegativeInfinity and b_ is S.Infinity: m = 0
    elif a_ is S.NegativeInfinity: m = b_ - 1
    elif b_ is S.Infinity: m = a_ + 1
    else: m = (a_ + b_) / 2
    v = f.subs(%s, m)
    if not v.is_comparable:
        v = v.evalf()
    cells.append("+" if v > 0 else "-")
    if i < len(pts):
        cells.append("||" if pts[i] in poles else "0")
pts_txt = ["-inf"] + [str(p) for p in pts] + ["+inf"]
print("X|" + " | ".join(pts_txt))
print("S|" + " | ".join(cells))
]==]):format(var, var, expr, var, var, var, var)
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_ERREUR") then
      return nil, "texecole : le calcul automatique n'a pas trouvé un "
        .. "nombre fini de zéros et de valeurs interdites pour « " .. expr
        .. " » — écrivez le tableau à la main avec la forme en bloc "
        .. "<signtab x:{...}>{ rangées }."
    end
    local rows = {}
    for line in (out .. "\n"):gmatch("(.-)\n") do
      local k, v = line:match("^(%u)|(.*)$")
      if k then rows[k] = to_moteur(v) end
    end
    if not (rows.X and rows.S) then
      return nil, "texecole : réponse SymPy inattendue :\n" .. out
    end
    return { x = rows.X, cells = rows.S }
  end

  function CALC_SYMPY.zeros(expr, var)
    var = var or "x"
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "zs = solveset(lire(\"%s\", %s), %s, domain=S.Reals)\n"
      .. "print(latex(zs))\n"):format(var, var, expr, var, var))
    if not out then return nil, err end
    return out
  end

  -- Racines n-ièmes complexes : l'ensemble des z tels que z^n = expr,
  -- construit explicitement depuis la branche principale — les n valeurs
  -- w^(1/n)·exp(2ikπ/n), k = 0..n-1, déjà rangées par argument croissant.
  function CALC_SYMPY.nroots(n, expr)
    local out, err = run((PRE .. "z = symbols('z')\n"
      .. "w = lire(\"%s\", z)\n"
      .. "r0 = Pow(w, Rational(1, %d))\n"
      .. "rs = FiniteSet(*[simplify(r0 * exp(2*I*pi*k/%d)) for k in range(%d)])\n"
      .. "print(latex(rs))\n"):format(expr, n, n, n))
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.primitive(expr, var)
    var = var or "x"
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(integrate(lire(\"%s\", %s), %s)))\n")
      :format(var, var, expr, var, var))
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.expand(expr)
    local out, err = run((PRE .. "x, y, z, t, a, b, c = symbols('x y z t a b c')\n"
      .. "print(latex(expand(lire(\"%s\", x))))\n"):format(expr))
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.apart(expr, var)
    var = var or "x"
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(apart(lire(\"%s\", %s), %s)))\n")
      :format(var, var, expr, var, var))
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.pde(eq, f)
    f = f or "u"
    local lhs, rhs = eq:match("^(.-)=(.+)$")
    if not lhs then
      return nil, "texecole : l'équation aux dérivées partielles demande un signe =."
    end
    local script = PRE .. ([==[
x, y = symbols('x y')
%s = Function('%s')
_d = {'x': x, 'y': y, '%s': %s, 'Derivative': Derivative}
l = parse_expr("%s", transformations=_T, local_dict=_d)
r = parse_expr("%s", transformations=_T, local_dict=_d)
try:
    print(latex(pdsolve(Eq(l, r), %s(x, y))))
except NotImplementedError:
    print("TEXECOLE_EDP_NON_RESOLUBLE")
]==]):format(f, f, f, f, lhs:gsub("%s+$", ""), rhs:gsub("^%s+", ""), f)
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_EDP_NON_RESOLUBLE") then
      return nil, "texecole : cette équation aux dérivées partielles dépasse "
        .. "le résoluble symboliquement (SymPy traite le premier ordre "
        .. "linéaire) — les EDP d'ordre supérieur relèvent du numérique."
    end
    return out
  end

  function CALC_SYMPY.ode(eq, f, var)
    f = f or "y"
    var = var or "x"
    local lhs, rhs = eq:match("^(.-)=(.+)$")
    if not lhs then
      return nil, "texecole : l'équation différentielle demande un signe =."
    end
    local script = PRE .. ([==[
%s = symbols('%s')
%s = Function('%s')
_d = {'%s': %s, '%s': %s, 'Derivative': Derivative}
l = parse_expr("%s", transformations=_T, local_dict=_d)
r = parse_expr("%s", transformations=_T, local_dict=_d)
print(latex(dsolve(Eq(l, r), %s(%s))))
]==]):format(var, var, f, f, var, var, f, f,
             lhs:gsub("%s+$", ""), rhs:gsub("^%s+", ""), f, var)
    local out, err = run(script)
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.eigen(rows_py)
    local script = PRE .. [==[
x, y, z, t, a, b, c, n = symbols('x y z t a b c n')
M = Matrix(]==] .. rows_py .. [==[)
if not M.is_square:
    print("TEXECOLE_NON_CARREE")
    raise SystemExit
ev = M.eigenvals()
parts = []
for v in sorted(ev.keys(), key=default_sort_key):
    s = latex(nsimplify(v))
    if ev[v] > 1: s += r"\;(\times %d)" % ev[v]
    parts.append(s)
print(r"\left\{" + r",\ ".join(parts) + r"\right\}")
]==]
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_NON_CARREE") then
      return nil, "texecole : les valeurs propres demandent une matrice carrée."
    end
    return out
  end

  function CALC_SYMPY.diagonalize(rows_py)
    local script = PRE .. [==[
x, y, z, t, a, b, c, n = symbols('x y z t a b c n')
M = Matrix(]==] .. rows_py .. [==[)
if not M.is_square:
    print("TEXECOLE_NON_CARREE")
    raise SystemExit
if not M.is_diagonalizable():
    print("TEXECOLE_NON_DIAGONALISABLE")
    raise SystemExit
P_, D_ = M.diagonalize()
print("P|" + latex(P_))
print("D|" + latex(D_))
]==]
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_NON_CARREE") then
      return nil, "texecole : la diagonalisation demande une matrice carrée."
    end
    if out:find("TEXECOLE_NON_DIAGONALISABLE") then
      return nil, "texecole : la matrice n'est pas diagonalisable (sur CC)."
    end
    local P_ = out:match("P|([^\n]*)")
    local D_ = out:match("D|([^\n]*)")
    if not (P_ and D_) then
      return nil, "texecole : réponse SymPy inattendue :\n" .. out
    end
    return P_, D_
  end

  function CALC_SYMPY.closed_sum(kind, expr, var, a, b)
    local op = (kind == "prod") and "product" or "summation"
    local head = (kind == "prod") and "Product" or "Sum"
    local script = PRE .. ([==[
%s = symbols('%s', integer=True)
x, y, z, t, n, p, q = symbols('x y z t n p q')
e = lire("%s", %s)
res = simplify(factor(%s(e, (%s, %s, %s))))
print("S|" + latex(%s(e, (%s, %s, %s))))
print("R|" + latex(res))
]==]):format(var, var, expr, var, op, var, a, b, head, var, a, b)
    local out, err = run(script)
    if not out then return nil, err end
    local S = out:match("S|([^\n]*)")
    local R = out:match("R|([^\n]*)")
    if not (S and R) then
      return nil, "texecole : réponse SymPy inattendue :\n" .. out
    end
    return S, R
  end

  function CALC_SYMPY.canonical(expr, var)
    var = var or "x"
    local script = PRE .. ([==[
%s = symbols('%s')
e = lire("%s", %s)
p = Poly(e, %s)
if p.degree() != 2:
    print("TEXECOLE_PAS_DEGRE_2")
    raise SystemExit
a_, b_, c_ = p.all_coeffs()
h = nsimplify(-b_ / (2*a_))
k_ = nsimplify(c_ - b_**2 / (4*a_))
print(latex(a_ * (%s - h)**2 + k_))
]==]):format(var, var, expr, var, var, var)
    local out, err = run(script)
    if not out then return nil, err end
    if out:find("TEXECOLE_PAS_DEGRE_2") then
      return nil, "texecole : la forme canonique est celle du trinôme du "
        .. "second degré — l'expression donnée n'est pas de degré 2."
    end
    return out
  end

  function CALC_SYMPY.nintegrate(expr, var, a, b)
    var = var or "x"
    local script = PRE .. ([==[
try:
    from scipy.integrate import quad
except ImportError:
    print("TEXECOLE_SCIPY_ABSENT")
    raise SystemExit
%s = symbols('%s')
f = lambdify(%s, lire("%s", %s), "math")
val, err = quad(f, %s, %s)
print(val)
]==]):format(var, var, var, expr, var, a, b)
    local out, e = run(script)
    if not out then return nil, e end
    if out:find("TEXECOLE_SCIPY_ABSENT") then
      return nil, "texecole : SciPy n'est pas installé. Installez-le : "
        .. python_cmd()
        .. " -m pip install scipy — l'intégrale numérique en a besoin."
    end
    return out
  end

  function CALC_SYMPY.limite(expr, var, point, dir)
    var = var or "x"
    local pt = point:gsub("infini", "oo")
    local d = dir == "droite" and ", dir='+'" or dir == "gauche" and ", dir='-'" or ""
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(limit(lire(\"%s\", %s), %s, %s%s)))\n")
      :format(var, var, expr, var, var, pt, d))
    if not out then return nil, err end
    return out
  end

  function CALC_SYMPY.dl(expr, var, a, n)
    var = var or "x"
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(series(lire(\"%s\", %s), %s, %s, %d).removeO()))\n")
      :format(var, var, expr, var, var, a, n + 1))
    if not out then return nil, err end
    local h = (a == "0") and var or ("(" .. var .. " - " .. a .. ")")
    return out .. " + o\\left(" .. h .. "^{" .. n .. "}\\right)"
  end

  function CALC_SYMPY.factor(expr)
    return run(PRE .. "x, y, z, t, a, b, c, n = symbols('x y z t a b c n')\n"
      .. "print(latex(factor(lire(\"" .. expr .. "\", x))))\n")
  end

  function CALC_SYMPY.simplify_expr(expr)
    return run(PRE .. "x, y, z, t, a, b, c, n = symbols('x y z t a b c n')\n"
      .. "print(latex(simplify(lire(\"" .. expr .. "\", x))))\n")
  end

  function CALC_SYMPY.solve_eq(lhs, rhs, var, domain)
    var = var or "x"
    domain = domain or "Reals"
    return run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(solveset(lire(\"%s\", %s) - (lire(\"%s\", %s)), %s, domain=S.%s)))\n")
      :format(var, var, lhs, var, rhs, var, var, domain))
  end

  local function rows_to_py(rows)
    local pyrows = {}
    for _, r in ipairs(rows) do
      local cells = {}
      for c in (r .. ";"):gmatch("(.-);") do
        c = c:gsub("^%s+", ""):gsub("%s+$", "")
        if c ~= "" then cells[#cells + 1] = "lire(\"" .. c .. "\", x)" end
      end
      pyrows[#pyrows + 1] = "[" .. table.concat(cells, ", ") .. "]"
    end
    return "[" .. table.concat(pyrows, ", ") .. "]"
  end

  CALC_SYMPY.rows_to_py = rows_to_py

  function CALC_SYMPY.matrix_op(rows, op)
    local body
    if op == "det" then
      body = "print(latex(nsimplify(M.det())))"
    elseif op == "inv" then
      body = ("if M.det() == 0:\n    print('TEXECOLE_NON_INVERSIBLE')\n"
        .. "else:\n    print(latex(M.inv()))")
    else
      body = "print(latex(M))"
    end
    local out, err = run(PRE
      .. "x, y, z, t, a, b, c, n = symbols('x y z t a b c n')\n"
      .. "M = Matrix(" .. rows_to_py(rows) .. ")\n" .. body .. "\n")
    if not out then return nil, err end
    if out:find("TEXECOLE_NON_INVERSIBLE") then
      return nil, "texecole : la matrice n'est pas inversible (déterminant nul)."
    end
    return out
  end

  function CALC_SYMPY.derivative(expr, var, order)
    var = var or "x"; order = order or 1
    local out, err = run((PRE .. "%s = symbols('%s')\n"
      .. "print(latex(simplify(diff(lire(\"%s\", %s), %s, %d))))\n")
      :format(var, var, expr, var, var, order))
    if not out then return nil, err end
    return out
  end

  local function frdec(s)
    if type(s) ~= "string" then return s end
    return (s:gsub("(%d)%.(%d)", "%1{,}%2"))
  end

  -- Arrondit chaque nombre décimal d'une expression LaTeX à la précision
  -- du document (option de classe precision=..., 3 par défaut) — les
  -- solutions numériques de SymPy arrivent avec 15 chiffres significatifs,
  -- illisibles pour un élève.
  local function arrondir_nombres(s, prec)
    return (s:gsub("%-?%d+%.%d+", function(num)
      local n = tonumber(num)
      if not n then return num end
      local shown = string.format("%." .. prec .. "f", n)
      shown = shown:gsub("0+$", ""):gsub("%.$", "")
      return shown
    end))
  end

  local function content_str(content)
    if type(content) == "table" then content = table.concat(content, "\n") end
    return content:gsub("^%s+", ""):gsub("%s+$", "")
  end

  local function content_rows(content)
    if type(content) == "table" then content = table.concat(content, "\n") end
    local rows = {}
    for l in (content .. "\n"):gmatch("(.-)\n") do
      l = l:gsub("^%s+", ""):gsub("%s+$", "")
      if l ~= "" then rows[#rows + 1] = l end
    end
    return rows
  end

  sl.register_block("sympyderiv", function(api, words, content)
    content = content_str(content)
    local name, order, var = words:match("^(%S+)%s+(%d+)%s+(%S+)$")
    if not name then
      name, var = words:match("^(%S+)%s+(%S+)$"); order = "1"
    end
    local latex, err = CALC_SYMPY.derivative(content, var or "x",
                                             tonumber(order) or 1)
    if not latex then error(err, 0) end
    local primes = string.rep("'", tonumber(order) or 1)
    api.lit("\\(" .. (name or "f") .. primes .. "(" .. (var or "x")
        .. ") = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympyzeros", function(api, words, content)
    content = content_str(content)
    local nom, var = words:match("^(%S+)%s+(%S+)$")
    var = var or "x"
    local latex, err = CALC_SYMPY.zeros(content, var)
    if not latex then error(err, 0) end
    local prec = tonumber(sl.config and sl.config.precision) or 3
    if prec < 0 then prec = 3 end
    latex = arrondir_nombres(latex, prec)
    local indice = nom and ("_{" .. nom .. "}") or ""
    api.lit("\\(\\mathcal{S}" .. indice .. " = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympynroots", function(api, words, content)
    content = content_str(content)
    local n = tonumber(words:match("^(%d+)")) or 2
    local latex, err = CALC_SYMPY.nroots(n, content)
    if not latex then error(err, 0) end
    api.lit("\\(\\mathcal{S} = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympyprim", function(api, words, content)
    content = content_str(content)
    local var = words:match("^%S+%s+(%S+)$") or "x"
    local latex, err = CALC_SYMPY.primitive(content, var)
    if not latex then error(err, 0) end
    api.lit("\\(\\displaystyle\\int " .. (words:match("^(%S+)") or "f")
      .. "(" .. var .. ")\\,\\mathrm{d}" .. var .. " = "
      .. frdec(latex) .. " + C\\)")
  end)

  sl.register_block("sympyexpand", function(api, words, content)
    content = content_str(content)
    local latex, err = CALC_SYMPY.expand(content)
    if not latex then error(err, 0) end
    api.lit("\\(" .. frdec(M.mathlite(content)) .. " = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympyapart", function(api, words, content)
    content = content_str(content)
    local latex, err = CALC_SYMPY.apart(content, "x")
    if not latex then error(err, 0) end
    api.lit("\\(" .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympyode", function(api, words, content)
    content = content_str(content)
    local f, var = words:match("^(%S+)%s+(%S+)$")
    if not f then f = words:match("^(%S+)") or "y" end
    local latex, err = CALC_SYMPY.ode(content, f, var)
    if not latex then error(err, 0) end
    api.lit("\\(" .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympypde", function(api, words, content)
    content = content_str(content)
    local f = words:match("^(%S+)") or "u"
    local latex, err = CALC_SYMPY.pde(content, f)
    if not latex then error(err, 0) end
    api.lit("\\(" .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympyeigen", function(api, words, content)
    local rows = content_rows(content)
    local latex, err = CALC_SYMPY.eigen(rows_to_py(rows))
    if not latex then error(err, 0) end
    api.lit("\\(\\operatorname{Sp}(" .. (words:match("^(%S+)") or "M")
      .. ") = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympydiag", function(api, words, content)
    local rows = content_rows(content)
    local P_, D_ = CALC_SYMPY.diagonalize(rows_to_py(rows))
    if not P_ then error(D_, 0) end
    local nom = words:match("^(%S+)") or "M"
    api.lit("\\(" .. nom .. " = P D P^{-1}\\) avec \\(P = " .. frdec(P_)
      .. "\\) et \\(D = " .. frdec(D_) .. "\\)")
  end)

  sl.register_block("sympysum", function(api, words, content)
    content = content_str(content)
    local var, a, b = words:match("^(%S+)%s+(%S+)%s+(%S+)$")
    local S, R = CALC_SYMPY.closed_sum("sum", content, var, a, b)
    if not S then error(R, 0) end
    api.lit("\\(\\displaystyle " .. frdec(S) .. " = " .. frdec(R) .. "\\)")
  end)

  sl.register_block("sympyprod", function(api, words, content)
    content = content_str(content)
    local var, a, b = words:match("^(%S+)%s+(%S+)%s+(%S+)$")
    local S, R = CALC_SYMPY.closed_sum("prod", content, var, a, b)
    if not S then error(R, 0) end
    api.lit("\\(\\displaystyle " .. frdec(S) .. " = " .. frdec(R) .. "\\)")
  end)

  sl.register_block("sympycanon", function(api, words, content)
    content = content_str(content)
    local latex, err = CALC_SYMPY.canonical(content, "x")
    if not latex then error(err, 0) end
    api.lit("\\(" .. frdec(M.mathlite(content)) .. " = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympynint", function(api, words, content)
    content = content_str(content)
    local name, var, a, b = words:match("^(%S+)%s+(%S+)%s+(%S+)%s+(%S+)$")
    local val, err = CALC_SYMPY.nintegrate(content, var, a, b)
    if not val then error(err, 0) end
    local prec = tonumber(sl.config and sl.config.precision) or 3
    if prec < 0 then prec = 3 end
    local num = tonumber(val)
    local shown = num and string.format("%." .. prec .. "f", num) or val
    shown = shown:gsub("0+$", ""):gsub("%.$", "")
    shown = shown:gsub("%.", "{,}")
    api.lit("\\(\\displaystyle\\int_{" .. a .. "}^{" .. b .. "} "
      .. (name or "f") .. "(" .. var .. ")\\,\\mathrm{d}" .. var
      .. " \\approx " .. shown .. "\\)")
  end)

  local function bloc_expr(nom, calc, prefixe)
    sl.register_block(nom, function(api, words, content)
      content = content_str(content)
      local latex, err = calc(content, words)
      if not latex then error(err, 0) end
      api.lit("\\(" .. prefixe(content, words) .. frdec(latex) .. "\\)")
    end)
  end

  bloc_expr("sympyfactor", function(c) return CALC_SYMPY.factor(c) end,
    function(c) return frdec(M.mathlite(c)) .. " = " end)
  bloc_expr("sympysimplify", function(c) return CALC_SYMPY.simplify_expr(c) end,
    function(c) return frdec(M.mathlite(c)) .. " = " end)

  sl.register_block("sympylimit", function(api, words, content)
    content = content_str(content)
    local name, var, pt, dir = words:match("^(%S+)%s+(%S+)%s+(%S+)%s*(%S*)$")
    local latex, err = CALC_SYMPY.limite(content, var, pt, dir ~= "" and dir or nil)
    if not latex then error(err, 0) end
    local ptx = pt:gsub("infini", "\\infty"):gsub("%+\\infty", "+\\infty")
    local sub = var .. " \\to " .. ptx
      .. (dir == "droite" and "^{+}" or dir == "gauche" and "^{-}" or "")
    api.lit("\\(\\displaystyle\\lim_{" .. sub .. "} " .. (name or "f")
      .. "(" .. var .. ") = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympydl", function(api, words, content)
    content = content_str(content)
    local name, var, a, n = words:match("^(%S+)%s+(%S+)%s+(%S+)%s+(%d+)$")
    local latex, err = CALC_SYMPY.dl(content, var, a, tonumber(n))
    if not latex then error(err, 0) end
    api.lit("\\(" .. (name or "f") .. "(" .. var .. ") = " .. frdec(latex) .. "\\)")
  end)

  sl.register_block("sympysolveeq", function(api, words, content)
    content = content_str(content)
    local lhs, rhs = content:match("^(.-)=(.+)$")
    if not lhs then error("texecole : <Résous l'équation ...> attend une équation avec un signe =.", 0) end
    local domain = words:match("^(%S+)")
    local latex, err = CALC_SYMPY.solve_eq(lhs, rhs, "x", domain)
    if not latex then error(err, 0) end
    api.lit("\\(\\mathcal{S} = " .. frdec(latex) .. "\\)")
  end)

  local function bloc_matrice(nom, op, texte)
    sl.register_block(nom, function(api, words, content)
      local rows = content_rows(content)
      local latex, err = CALC_SYMPY.matrix_op(rows, op)
      if not latex then error(err, 0) end
      api.lit("\\(" .. texte:gsub("NOM", words:match("^(%S+)") or "M")
        .. " " .. frdec(latex) .. "\\)")
    end)
  end
  bloc_matrice("sympydet", "det", "\\det(NOM) =")
  bloc_matrice("sympyinv", "inv", "NOM^{-1} =")

  sl.sympy = CALC_SYMPY
  return CALC_SYMPY
end
