import * as R from 'ramda'
import { expect } from 'chai'
import daggy from 'daggy'
import { Maybe as M } from 'ramda-fantasy'
import { StateT, Writer } from 'akh'

describe('simplified tree walks', function() {
	// Notre domaine peut se simplifier à une liste d'équations à trous:
	// a: 45
	// b: a + c
	// d: a + 4
	// e: b + d
	// Disons que je veux connaitre "e", alors il va me manquer "c"
	// Si je connais "c", alors je peux calculer "e"
	// Et mon ambition est aussi de pouvoir visualiser le calcul en HTML
	// Donc j'ai une structure plate que je transforme en arbre (ce n'est pas
	// le focus de la présente exploration), je veux pouvoir demander des choses
	// diverses à cet arbre: l'évaluer, repérer les trous, le transformer en HTML

	// Plus tard je vais avoir des trucs plus sophistiqués, par exemple:
	// 	b: a + (bleu: b, vert: c)
	// qui est équivalent à:
	// 	b: b-bleu + b-vert
	// 	b-bleu: a + b
	// 	b-vert: a + c
	// Le but du jeu est de pouvoir le représenter de façon compacte, mais
	// d'avoir un arbre simple à manipuler

	// Pour intégrer dans le simulateur, il faut remplir les exigences
	// suivantes:
	// X décorer l'arbre avec une valeur à chaque noeud
	// X réaliser le calcul de façon efficiente (1 fois par variable)
	// - savoir "court-circuiter" le calcul de variables manquantes dans les conditionnelles
	// - avoir un moyen de gérer les composantes et filtrage

	// Ce qu'on décrit est un framework de programmation déclarative: on stipule des
	// définitions (salaire net = brut - cotisations) mais on les donne sans ordre
	// impératif, on laisse au moteur le soin de calculer les dépendances

	// Chaque élément de notre base de règles est une définition:

	const Def = daggy.taggedSum('Def', {
		Assign: ['name', 'expr']
	})
	const { Assign } = Def

	// Par contre, à l'exécution, il faut bien calculer des "effets de bord"
	// pour rester performant: chaque évaluation d'une définition doit mettre
	// à jour le 'dictionnaire' des valeurs connues, puis le mettre à disposition
	// de la suite du calcul - on verra comment au Chapitre 3

	// La partie droite d'une définition est une expression:

	const Expr = daggy.taggedSum('Expr', {
		Num: ['x'],
		Add: ['x', 'y'],
		Var: ['name']
		//		NotIf:  ['condition','formule'],
		//		OnlyIf: ['condition','formule'],
		//		AnyOf:  ['conditions'],
		//		AllOf:  ['conditions'],
	})
	const { Num, Add, Var } = Expr

	// Chapitre 1...

	// Le type Expr est la traduction en JS du type suivant en Haskell,
	// "naivement récursif":
	// data Expr = Num Int | Var String | Add Expr Expr

	// Il se trouve qu'on peut gagner beaucoup en introduisant une petite
	// complexité: on va exprimer la récursion avec un niveau d'indirection,
	// la première étape étant de rendre le type polymorphique sur ce qui
	// est récursif:

	// data ExprF r = Num Int | Var String | Add r r

	// Par exemple, une addition de deux additions c'est de type ExprF (ExprF r),
	// et si je veux décrire des imbrications plus poussées d'additions dans
	// des additions il me faudra un ExprF (ExprF (ExprF r)) et ainsi de
	// suite: on a "déroulé" la récursion dans le type d'origine.

	// On peut alors retrouver le type d'origine en introduisant un
	// "constructeur de point fixe de type", appelé Fx, et en introduisant
	// ce qu'on appelle un "functor type" (c'est le suffixe F)

	// data Expr = Fx ExprF

	// Le point fixe de f est une solution à l'équation x = f x - on
	// peut l'appliquer à des fonctions récursives, voir par exemple:
	// https://www.vex.net/~trebla/haskell/fix.xhtml

	// En JS ça ne marche pas parce que JS est strict et non lazy...

	// Quand au point fixe d'un type, c'est le point fixe de son
	// constructeur: une solution à l'équation T = Fx T

	// En JS c'est juste une fonction qui emballe et une qui déballe:

	const Fx = daggy.tagged('Fx', ['x'])
	Fx.prototype.project = function() {
		return this.x
	}
	const unFix = fx => fx.project()

	// Les helpers suivants rendent moins pénible la construction de valeurs
	// notamment pour les tests

	let num = x => Fx(Num(x))
	let add = (x, y) => Fx(Add(x, y))
	let ref = name => Fx(Var(name))

	// Une application de la théorie des catégories permet de dériver
	// la fonction "fold" suivante, qui généralise aux structures récursives
	// la notion de "reduction" (comme pour les listes), on l'appelle aussi
	// un catamorphisme

	// fold :: Functor f => (f a -> a) -> Fix f -> a
	const fold = R.curry((algebra, x) =>
		R.compose(algebra, R.map(fold(algebra)), unFix)(x)
	)

	// Cf. https://www.schoolofhaskell.com/user/bartosz/understanding-algebras

	// Dans ce contexte, un "algebre" est une fonction qui nous dit comment calculer
	// la réduction pour un noeud à partir des valeurs calculées pour les noeuds fils

	// Cette fonction fournit la traversée
	Expr.prototype.map = function(f) {
		return this.cata({
			Num: x => this, // fixed
			Add: (x, y) => Add(f(x), f(y)),
			Var: name => this
		})
	}

	// Celle-ci l'évaluation
	const evaluator = state => a => {
		return a.cata({
			Num: x => M.Just(x),
			Add: (x, y) => R.lift(R.add)(x, y),
			Var: name => M.toMaybe(state[name]) // Doesn't typecheck
		})
	}

	let evaluate = (expr, state = {}) =>
		fold(evaluator(state), expr).getOrElse(null) // for convenience

	// Voici donc l'évaluation d'un arbre...

	it('should provide a protocol for evaluation', function() {
		let tree = num(45),
			result = evaluate(tree)
		expect(result).to.equal(45)
	})

	it('should evaluate expressions', function() {
		let tree = add(num(45), num(25)),
			result = evaluate(tree)
		expect(result).to.equal(70)
	})

	it('should evaluate nested expressions', function() {
		let tree = add(num(45), add(num(15), num(10))),
			result = evaluate(tree)
		expect(result).to.equal(70)
	})

	// Problème: on évalue l'arbre tout entier d'un seul coup; mais
	// peut-on aussi "décorer" l'arbre pendant sa traversée avec les
	// valeurs intermédiaires ? On verra que oui, au Chapitre 2; en
	// attendant on voudrait aussi savoir quelles sont les variables
	// manquantes...

	const collector = state => a => {
		return a.cata({
			Num: x => [],
			Add: (x, y) => R.concat(x, y),
			Var: name => (state[name] ? [] : [name])
		})
	}

	let missing = (expr, state = {}) => fold(collector(state), expr)

	it('should evaluate expressions involving variables', function() {
		let tree = add(num(45), ref('a')),
			result = evaluate(tree, { a: 25 })
		expect(result).to.equal(70)
	})

	it('should evaluate expressions involving missing variables', function() {
		let tree = add(num(45), ref('b')),
			result = evaluate(tree, { a: 25 })
		expect(result).to.equal(null)
	})

	it('should provide a protocol for missing variables', function() {
		let tree = ref('a'),
			result = missing(tree)
		expect(result).to.deep.equal(['a'])
	})

	it('should locate missing variables in expressions', function() {
		let tree = add(num(45), ref('a')),
			result = missing(tree)
		expect(result).to.deep.equal(['a'])
	})

	it('should locate missing variables in nested expressions', function() {
		let tree = add(add(num(35), ref('a')), num(25)),
			result = missing(tree)
		expect(result).to.deep.equal(['a'])
	})

	it('should locate missing variables in nested expressions', function() {
		let tree = add(add(num(35), ref('a')), num(25)),
			result = missing(tree, { a: 25 })
		expect(result).to.deep.equal([])
	})

	// Chapitre 2...

	// Pour annoter l'arbre avec les valeurs intermédiaires on utilise un
	// type "Cofree Comonad": ce sont des paires (fst,snd) dont la première
	// valeur est un noeud de l'arbre et la seconde l'annotation; on a un
	// constructeur ann et une fonction de lecture

	// Cf https://github.com/willtim/recursion-schemes/
	// or http://www.timphilipwilliams.com/slides/HaskellAtBarclays.pdf

	const AnnF = daggy.tagged('AnnF', ['fr', 'a'])
	let ann = ({ fst, snd }) => Fx(AnnF(fst, snd))
	let nodeValue = annf => {
		let { fr, a } = unFix(annf)
		return a
	}

	// fork est l'opérateur "&&&" de Haskell: (f &&& g) x = Pair(f(x),g(x))
	let fork = (f, g) => x => ({ fst: f(x), snd: g(x) })

	// synthesize combine l'application d'un algèbre fourni f et de l'annotation
	let synthesize = f => {
		let algebra = f =>
			R.compose(ann, fork(R.identity, R.compose(f, R.map(nodeValue))))
		return fold(algebra(f))
	}

	let annotate = (state, tree) => synthesize(evaluator(state))(tree)

	it('should annotate tree with evaluation results', function() {
		let tree = add(num(45), add(num(15), num(10))),
			result = nodeValue(annotate({}, tree)).getOrElse(null)
		expect(result).to.equal(70)
	})

	// Chapitre 3

	// On sait evaluer des expressions, il faut aussi être capable de
	// gérer les règles définissant les variables appelées dans ces
	// expressions; voyons ce que ça donne avec un algèbre plus simple:

	let calculate = R.curry((rules, name) => {
		let find = (rules, name) =>
				R.find(x => R.prop('name', x) == name, rules).expr,
			expr = find(rules, name)
		return fold(evaluator2(calculate(rules)), expr)
	})

	const evaluator2 = calculate => a => {
		return a.cata({
			Num: x => x,
			Add: (x, y) => x + y,
			Var: name => calculate(name)
		})
	}

	it('should resolve variable dependencies', function() {
		let rule1 = Assign('a', add(ref('b'), ref('b'))),
			rule2 = Assign('b', num(15)),
			rules = [rule1, rule2],
			result = calculate(rules, 'a')
		expect(result).to.equal(30)
	})

	// Utilisons un Writer (un idiome fonctionnel pour par exemple écrire des logs)
	// pour examiner le calcul de plus près.

	const Str = daggy.tagged('Str', ['s'])
	Str.zero = Str('')
	Str.prototype.zero = Str.zero
	Str.prototype.concat = function(b) {
		return Str(this.s + b.s)
	}

	let trace = R.curry((rules, name) => {
		let find = (rules, name) =>
				R.find(x => R.prop('name', x) == name, rules).expr,
			expr = find(rules, name)
		return fold(tracer(trace(rules)), expr)
	})

	const tracer = recurse => a => {
		let log = (x, s) => Writer.tell(Str(s)).map(_ => x)
		return a.cata({
			Num: x => log(x, x + ','),
			Add: (x, y) => x.chain(xx => y.chain(yy => log(xx + yy, '+,'))),
			Var: name => recurse(name).chain(x => log(x, name + ','))
		})
	}

	// On voit qu'on a calculé la valeur de b 2 fois! Ce n'est pas utile,
	// puisque cette valeur ne changera pas au cours du calcul; et comme on
	// répète le calcul autant de fois qu'il y a de références à une variable
	// donnée, si l'arbre est un tant soit peu complexe les performances seront
	// très mauvaises.

	it('should trace the shape of the computation', function() {
		let rule1 = Assign('a', add(ref('b'), ref('b'))),
			rule2 = Assign('b', num(15)),
			rules = [rule1, rule2],
			result = trace(rules, 'a').run(Str.zero)
		expect(result.value).to.equal(30)
		expect(result.output.s).to.equal('15,b,15,b,+,')
	})

	// Pour corriger ce problème on va avoir besoin de formuler une version
	// "monadique" du catamorphisme, c'est-à-dire qu'on va pouvoir l'associer
	// à un contexte (ou monade) dans lequel tout le calcul va se dérouler,
	// et qui va pouvoir accumuler des informations au fur et à mesure, par
	// exemple un cache des variables déjà calculées.

	// On a déjà vu un exemple de monade, c'était Writer: voyons comment on
	// reformule le catamorphisme pour qu'il se déroule dans la monade Writer.
	// L'implémentation de cataM est inspirée de
	//   https://github.com/DrBoolean/excursion/
	// D'abord on ajoute de la plomberie:

	const cataM = (of, algM) => m =>
		m
			.project()
			.traverse(of, x => x.cataM(of, algM))
			.chain(algM)

	const traverse = function(of, f) {
		return this.cata({
			Num: x => of(this),
			Add: (x, y) => f(x).chain(xx => f(y).chain(yy => of(Add(xx, yy)))),
			Var: name => of(this)
		})
	}
	Expr.prototype.traverse = traverse
	Fx.prototype.cataM = function(of, alg) {
		return cataM(of, alg)(this)
	}

	// Maintenant que c'est fait on voit qu'on a simplifié l'expression du
	// catamorphisme: on n'a plus à expliciter l'enchaînement (sauf pour la
	// récursion de plus haut niveau dans les variables)

	let trace2 = R.curry((rules, name) => {
		let find = (rules, name) =>
				R.find(x => R.prop('name', x) == name, rules).expr,
			expr = find(rules, name)
		return cataM(Writer.of, tracer2(trace2(rules)))(expr)
	})

	const tracer2 = recurse => a => {
		let log = (x, s) => Writer.tell(Str(s)).map(_ => x)
		return a.cata({
			Num: x => log(x, x + ','),
			Add: (x, y) => log(x + y, '+,'),
			Var: name => recurse(name).chain(x => log(x, name + ','))
		})
	}

	it('should trace the shape of the computation, showing two passes through b', function() {
		let rule1 = Assign('a', add(ref('b'), ref('c'))),
			rule2 = Assign('b', num(15)),
			rule3 = Assign('c', num(10)),
			rules = [rule1, rule2, rule3],
			result = trace2(rules, 'a').run(Str.zero)
		expect(result.value).to.equal(25)
		expect(result.output.s).to.equal('15,b,10,c,+,')
	})

	// On a la possibilité "d'encapsuler" une monade dans une autre:
	// on va se doter d'un State, une monade qui permet de stocker un
	// état et de le modifier en le propageant dans tout le calcul, et
	// conserver Writer à l'intérieur (on utilise la variante StateT,
	// le T veut dire "transformation de monade")

	// On peut aller plus loin et mémoiser le catamorphisme:
	// https://idontgetoutmuch.wordpress.com/2011/05/15/monadic-caching-folds/
	// ça ne semble pas nécessaire ici puisque tout se passe au niveau de
	// la récursion sur "Var"

	const S = StateT(Writer)
	const log = (x, s) => S.lift(S.inner.tell(Str(s)).map(_ => x))

	let trace3 = R.curry((rules, name) => {
		let find = (rules, name) =>
				R.find(x => R.prop('name', x) == name, rules).expr,
			expr = find(rules, name)
		return cataM(S.of, tracer3(trace3(rules)))(expr)
	})

	const memoize = f => name => {
		let cache = result =>
			result.chain(x =>
				result
					.modify(state => R.assoc(name, run(result), state))
					.chain(z => S.of(x))
			)

		return S.get.chain(state => {
			let cached = state[name]
			return cached ? S.of(cached.value.value) : cache(f(name))
		})
	}

	const tracer3 = recurse => a => {
		return a.cata({
			Num: x => log(x, x + ','),
			Add: (x, y) => log(x + y, '+,'),
			Var: memoize(name => recurse(name).chain(x => log(x, name + ',')))
		})
	}

	const run = (c, state) => Writer.run(StateT.run(c, state), Str.zero)

	it('should trace the shape of the computation, showing one pass through b', function() {
		let rule1 = Assign('a', add(ref('b'), ref('b'))),
			rule2 = Assign('b', num(15)),
			rules = [rule1, rule2],
			result = run(trace3(rules, 'a'), {})
		expect(result.value.value).to.equal(30)
		expect(result.output.s).to.equal('15,b,+,')
	})
})