// organ_converse.el — full-duplex, interruptible speech. The turn-taking organ.
//
// WHAT THIS IS FOR. A system that plays an utterance to completion and only
// then listens is not conversational, it is a loudspeaker with a queue. Being
// interruptible is not a feature bolted onto speech; it is most of what makes
// speech social. So the utterance is not a blob of audio — it is an ordered,
// SALIENCE-TAGGED MEANING-PLAN, and the organ speaks it while listening, decides
// what to do when interrupted, and can pick the thread back up afterwards.
//
// THREE THINGS HAVE TO BE TRUE, and each one is a place naive implementations
// go wrong:
//
//   Barge-in is AT THE SAMPLE. When the mic hears speech, output stops on the
//   spot — not at the end of the current buffer, not at the end of the segment.
//   A listener experiences even a fifth of a second of continued talking as
//   being talked over. This is why the speaker realizer has pause/resume and
//   reports played_frames: "finish the buffer" is not barge-in.
//
//   Yield-or-hold is a DECISION, not a rule. Stopping every time anyone makes a
//   noise is its own failure — it means Neuron can never finish a sentence that
//   matters. So the choice is grounded: how salient is what I am mid-saying,
//   how close am I to done, and how much authority does the interrupter have.
//   Holding the floor is justified when what I am saying matters AND finishing
//   is cheap AND the interrupter is not high-priority. Otherwise yield, because
//   the polite default is the right default.
//
//   A backchannel is NOT an interruption. "mm-hm" means keep going. Treating it
//   as a barge-in makes the system stop every three seconds during ordinary
//   listening behaviour, which is worse than not listening at all. It is
//   distinguished by being brief and low-energy: sample again shortly after
//   onset, and if the speech already died away it was a backchannel.
//
// AND THE UTTERANCE SURVIVES. On yield, the remaining plan is persisted, so
// Neuron can resume — "as I was saying" — instead of losing the thought. An
// interruption should cost a turn, not the content.
//
// The AEC rail: the microphone runs with the OS voice-processing unit enabled
// so it does not hear our own speaker. Without it Neuron barges in on its own
// voice on the first syllable and the whole loop is unusable in a real room.
//
// Note what is NOT here: nothing about words. A segment carries a `text` field
// purely as a label for disclosure. The organ speaks pre-rendered audio and
// never inspects language — that is the language faculty's, and the seam holds.

// ── The meaning-plan ─────────────────────────────────────────────────────────
//
// Stored as a flat [String] with stride 3 — file, salience-per-mille, text —
// because El has no record type and parallel lists drift out of step under
// editing. Salience is an integer per-mille rather than a Float so the decision
// arithmetic stays exact and reproducible; a turn-taking decision that varies
// with floating-point rounding is not one you can debug.

fn plan_new() -> [String] {
    return native_list_empty()
}

fn plan_add(plan: [String], file: String, salience_pm: Int, text: String) -> [String] {
    let p: [String] = plan
    p = native_list_append(p, file)
    p = native_list_append(p, int_to_str(salience_pm))
    p = native_list_append(p, text)
    return p
}

fn plan_count(plan: [String]) -> Int {
    return native_list_len(plan) / 3
}

fn plan_file(plan: [String], i: Int) -> String {
    return native_list_get(plan, i * 3)
}

fn plan_salience(plan: [String], i: Int) -> Int {
    return str_to_int(native_list_get(plan, i * 3 + 1))
}

fn plan_text(plan: [String], i: Int) -> String {
    return native_list_get(plan, i * 3 + 2)
}

// ── Manifest ─────────────────────────────────────────────────────────────────
//
// {"utterance": "...", "segments": [{"file":..., "salience":0.9, "text":"..."}]}
// Salience arrives as a 0..1 float in the manifest and is converted once, here,
// at the edge — the same discipline the runtime uses for wire encodings.

fn conv_salience_pm(raw: String) -> Int {
    // "0.85" -> 850. Parsed by hand rather than through a float so a manifest
    // typo degrades to a visible number instead of a silent 0.0.
    let dot: Int = str_index_of(raw, ".")
    if dot < 0 {
        let whole: Int = str_to_int(raw)
        return whole * 1000
    }
    let ip: Int = str_to_int(str_slice(raw, 0, dot))
    let frac: String = str_slice(raw, dot + 1, str_len(raw))
    let pm: Int = 0
    let scale: Int = 100
    let i: Int = 0
    while i < 3 {
        let d: Int = 0
        if i < str_len(frac) {
            let c: Int = str_char_code(frac, i)
            if c >= 48 {
                if c <= 57 {
                    d = c - 48
                }
            }
        }
        pm = pm + d * scale
        scale = scale / 10
        i = i + 1
    }
    return ip * 1000 + pm
}

fn conv_load_manifest(path: String) -> [String] {
    let plan: [String] = plan_new()
    let raw: String = fs_read(path)
    if str_eq(raw, "") {
        organ_disclose("CONVERSE: cannot read manifest " + path)
        return plan
    }
    let segs: String = json_get_raw(raw, "segments")
    let n: Int = json_array_len(segs)
    let i: Int = 0
    while i < n {
        let seg: String = json_array_get(segs, i)
        let file: String = json_get_string(seg, "file")
        let text: String = json_get_string(seg, "text")
        let sal: String = json_get_raw(seg, "salience")
        let pm: Int = conv_salience_pm(sal)
        if pm <= 0 {
            pm = 500
        }
        plan = plan_add(plan, file, pm, text)
        i = i + 1
    }
    return plan
}

fn conv_utterance(path: String) -> String {
    let raw: String = fs_read(path)
    return json_get_string(raw, "utterance")
}

// ── The decision ─────────────────────────────────────────────────────────────
//
// Returns: 0 = backchannel, carry on seamlessly
//          1 = hold the floor ("hang on, let me finish this thought")
//          2 = yield (stop, let them in)
//
// All arguments are per-mille integers. Holding requires BOTH that the material
// is worth finishing AND that the interrupter is not high-authority — either
// condition alone is not enough, because "what I'm saying is important" is
// exactly the reasoning that produces a system nobody can get a word in against.
fn conv_decide(salience_pm: Int, progress_pm: Int, authority_pm: Int, is_backchannel: Bool) -> Int {
    if is_backchannel {
        return 0
    }
    let hold_score: Int = (salience_pm * 6 + progress_pm * 4) / 10
    if hold_score >= 600 {
        if authority_pm < 800 {
            return 1
        }
    }
    return 2
}

// ── Resume ───────────────────────────────────────────────────────────────────
//
// The remaining plan, written where a later run can find it. This is what turns
// an interruption into a pause rather than a loss.

fn conv_resume_path() -> String {
    let home: String = env("PERIPH_HOME")
    if str_eq(home, "") {
        return "peripheral/.resume.json"
    }
    return home + "/.resume.json"
}

// Minimal JSON string escaping. Written here rather than reached for from the
// runtime because the organ needs exactly two escapes and no dependency: a
// segment label containing a quote or a backslash must not be able to produce a
// resume file that fails to parse and silently loses the thread.
fn conv_escape(s: String) -> String {
    let n: Int = str_len(s)
    let out: String = ""
    let i: Int = 0
    while i < n {
        let c: Int = str_char_code(s, i)
        if c == 34 {
            out = out + "\\\""
        } else {
            if c == 92 {
                out = out + "\\\\"
            } else {
                if c >= 32 {
                    out = out + str_slice(s, i, i + 1)
                }
            }
        }
        i = i + 1
    }
    return out
}

fn conv_persist_resume(plan: [String], start_at: Int, reason: String) -> Bool {
    let n: Int = plan_count(plan)
    let body: String = "{\"resume_from\": " + int_to_str(start_at) + ", \"reason\": \"" + reason + "\", \"segments\": ["
    let i: Int = start_at
    let first: Bool = true
    while i < n {
        if first == false {
            body = body + ", "
        }
        body = body + "{\"file\": \"" + plan_file(plan, i) + "\", \"salience\": " + int_to_str(plan_salience(plan, i)) + ", \"text\": \"" + conv_escape(plan_text(plan, i)) + "\"}"
        first = false
        i = i + 1
    }
    body = body + "]}\n"
    let ok: Bool = fs_write(conv_resume_path(), body)
    organ_disclose("CONVERSE: meaning-plan persisted (" + int_to_str(n - start_at) + " segments remain) — Neuron can resume the thread.")
    return ok
}

fn conv_clear_resume() -> Bool {
    return fs_write(conv_resume_path(), "")
}

// Read a persisted plan back. Salience is already per-mille here (we wrote it),
// so it is NOT re-scaled — the manifest and the resume file are different
// formats on purpose, and conflating them silently divides every salience by a
// thousand.
fn conv_load_resume() -> [String] {
    let plan: [String] = plan_new()
    let raw: String = fs_read(conv_resume_path())
    if str_eq(raw, "") {
        return plan
    }
    let segs: String = json_get_raw(raw, "segments")
    let n: Int = json_array_len(segs)
    let i: Int = 0
    while i < n {
        let seg: String = json_array_get(segs, i)
        plan = plan_add(plan, json_get_string(seg, "file"), json_get_int(seg, "salience"), json_get_string(seg, "text"))
        i = i + 1
    }
    return plan
}

// ── The loop ─────────────────────────────────────────────────────────────────
//
// live_mic  : open the microphone with AEC and let real speech drive barge-in.
// barge_ms  : if >= 0, inject a barge event at that offset into the utterance
//             instead. Deterministic, so the decision paths can be exercised
//             without a room and a person — the same reason periph.swift has it.
// kind      : "backchannel" or "bargein", for the injected case.
// authority : interrupter authority, per-mille.
//
// Returns the index the utterance stopped at, or -1 if it completed.

fn conv_run(plan: [String], authority_pm: Int, barge_ms: Int, kind: String, live_mic: Bool) -> Int {
    let n: Int = plan_count(plan)
    if n <= 0 {
        organ_disclose("CONVERSE: nothing to say.")
        return 0 - 1
    }
    if speaker_available() == 0 {
        organ_disclose("CONVERSE: no speaker on this build — cannot hold a conversation.")
        return 0 - 1
    }

    let mic_live: Bool = false
    if live_mic {
        if organ_may_listen() {
            let m: Int = mic_monitor_start()
            if m == 1 {
                organ_disclose("CONVERSE: full-duplex — mic listening WHILE speaking, AEC on (won't self-interrupt).")
                mic_live = true
            }
            if m == 2 {
                organ_disclose("CONVERSE: full-duplex — mic listening, but AEC UNAVAILABLE; raising the VAD floor so we do not barge in on ourselves.")
                mic_live = true
            }
            if m == 0 {
                organ_disclose("CONVERSE: could not open the mic monitor — falling back to injected events.")
            }
        }
    }
    if mic_live == false {
        organ_disclose("CONVERSE: deterministic mode (live mic off).")
    }

    // Without AEC the mic hears the speaker, so the threshold has to sit above
    // our own output. This is a mitigation and not a fix: the honest note is
    // that barge-in is markedly less sensitive in this mode.
    let vad_pm: Int = 20
    if mic_live {
        if mic_monitor_start() == 2 {
            vad_pm = 60
        }
    }

    let elapsed_ms: Int = 0
    let prior_ms: Int = 0
    let handled: Bool = false
    // An injected barge is ONE event, not a condition that stays true. Without
    // this the deadline re-fires on every poll after a backchannel resume, and
    // the utterance live-locks: paused, resumed, paused again, forever.
    let injected_fired: Bool = false
    let i: Int = 0

    while i < n {
        let file: String = plan_file(plan, i)
        let sal: Int = plan_salience(plan, i)
        let frames: Int = wav_frames(file)
        let rate: Int = wav_rate(file)
        if frames <= 0 {
            organ_disclose("CONVERSE: missing or unreadable segment '" + file + "', skipping.")
            i = i + 1
        } else {
            let dur_ms: Int = frames * 1000 / rate
            organ_disclose("CONVERSE: speaking segment " + int_to_str(i + 1) + "/" + int_to_str(n) + " (salience " + int_to_str(sal) + "/1000) — \"" + plan_text(plan, i) + "\"")
            let started: Int = speaker_play_wav_async(file)
            if started == 0 {
                organ_disclose("CONVERSE: could not start playback for '" + file + "'.")
                i = i + 1
            } else {
                let seg_ms: Int = 0
                let done: Bool = false
                let interrupted: Bool = false
                let speech_ticks: Int = 0

                while done == false {
                    sleep_ms(10)
                    seg_ms = seg_ms + 10

                    if speaker_playing() == 0 {
                        done = true
                    } else {
                        // The tick counter is an approximation — each pass costs
                        // more than the sleep it asked for. The DAC position is
                        // the truth, so drive the injected deadline off THAT and
                        // an injected barge lands where it was asked to land.
                        let pos_ms: Int = speaker_played_frames() * 1000 / rate
                        elapsed_ms = prior_ms + pos_ms
                        // --- onset detection: real speech, or an injected event ---
                        let onset: Bool = false
                        if mic_live {
                            let rms: Float = mic_monitor_rms()
                            let rms_pm: Int = float_to_int(rms * 1000.0)
                            if rms_pm > vad_pm {
                                speech_ticks = speech_ticks + 1
                            } else {
                                speech_ticks = 0
                            }
                            // ~60ms of continuous voice: short enough to feel
                            // instant, long enough that a door closing is not a turn.
                            if speech_ticks >= 3 {
                                if handled == false {
                                    onset = true
                                }
                            }
                        }
                        if barge_ms >= 0 {
                            if injected_fired == false {
                                if elapsed_ms >= barge_ms {
                                    onset = true
                                    injected_fired = true
                                }
                            }
                        }

                        if onset {
                            handled = true
                            // (1) BARGE-IN — pause on the spot.
                            speaker_pause()
                            let played: Int = speaker_played_frames()
                            let at_ms: Int = played * 1000 / rate
                            let progress_pm: Int = at_ms * 1000 / dur_ms
                            if progress_pm > 1000 {
                                progress_pm = 1000
                            }
                            organ_disclose("CONVERSE: << user speech at " + int_to_str(at_ms) + "ms into segment " + int_to_str(i + 1) + " — PAUSED instantly >>")

                            // (2) backchannel or real barge-in?
                            let is_bc: Bool = false
                            if barge_ms >= 0 {
                                if str_eq(kind, "backchannel") {
                                    is_bc = true
                                }
                            } else {
                                // Live: look again ~250ms after onset. If the
                                // energy has already collapsed it was "mm-hm".
                                sleep_ms(250)
                                let r2: Float = mic_monitor_rms()
                                if float_to_int(r2 * 1000.0) < 15 {
                                    is_bc = true
                                }
                            }

                            // (3) yield, hold, or carry on
                            let d: Int = conv_decide(sal, progress_pm, authority_pm, is_bc)
                            if d == 0 {
                                organ_disclose("CONVERSE: read as BACKCHANNEL (\"mm-hm\") — keep going, resume seamlessly.")
                                handled = false
                                speech_ticks = 0
                                speaker_resume()
                            }
                            if d == 1 {
                                organ_disclose("CONVERSE: HOLD the floor — \"hang on, let me finish this thought.\" (salience " + int_to_str(sal) + ", progress " + int_to_str(progress_pm) + ")")
                                speaker_resume()
                                // Finish THIS segment, then yield the remainder:
                                // holding is a request for a moment, not a claim
                                // on the rest of the conversation.
                                while speaker_playing() == 1 {
                                    sleep_ms(20)
                                }
                                speaker_stop()
                                conv_persist_resume(plan, i + 1, "held-then-yield")
                                if mic_live {
                                    mic_monitor_stop()
                                }
                                return i + 1
                            }
                            if d == 2 {
                                organ_disclose("CONVERSE: YIELD — stop, let them in. Remembering where I was (resumable).")
                                speaker_stop()
                                conv_persist_resume(plan, i, "yield")
                                if mic_live {
                                    mic_monitor_stop()
                                }
                                return i
                            }
                        }
                    }
                }
                if interrupted == false {
                    prior_ms = prior_ms + dur_ms
                    i = i + 1
                }
            }
        }
    }

    conv_clear_resume()
    organ_disclose("CONVERSE: utterance complete (uninterrupted).")
    if mic_live {
        mic_monitor_stop()
    }
    return 0 - 1
}
