//! Tier-4 PTY-based end-to-end tests (ADR-0008 §Tier-4, requirements TT4).
//!
//! These drive the **actual built binary** in a real pseudo-terminal and
//! assert on what a user would see on screen — catching what the lower
//! tiers can't: TTY setup, raw-mode / alternate-screen transitions, real
//! I/O timing, and graceful quit. The four flows mirror ADR-0008's
//! initial Tier-4 scope exactly:
//!
//!   1. cold launch → first DDL command → graceful quit
//!   2. project save → reopen → identical state
//!   3. project export → import into a fresh project → rebuilt state
//!   4. `undo` immediately after `DROP TABLE` (incl. the confirm modal)
//!
//! Tooling (ADR-0008, refined): `portable-pty` to spawn the binary in a
//! PTY at a fixed window size, `vt100` to parse the output stream into an
//! inspectable screen grid. ADR-0008 also named `expectrl`; we dropped it
//! — it bundles its own PTY abstraction (conflicts with portable-pty) and
//! is line-oriented, a poor fit for a full-screen TUI. A small hand-rolled
//! `wait_for` polling the vt100 screen replaces it.
//!
//! Determinism: each test gets its own temp `--data-dir` (no contact with
//! the user's real projects / resume state), a fixed 100×30 terminal (the
//! wide three-region layout, ADR-0046), and `--theme dark`. Tests run
//! **serially** (one PTY + child process at a time) so timing stays
//! predictable on the low-parallelism self-hosted CI runner, with tight,
//! fail-fast timeouts: a slow wait means a real hang, not contention.

// `PtyApp` deliberately holds a serial `MutexGuard` for its whole lifetime
// (to run PTY tests one at a time), and the screen-reading helpers hold the
// vt100 parser lock for the duration of a read. `significant_drop_tightening`
// flags both as droppable-earlier, but tightening them is either impossible
// (the guard *is* the serialization) or pointless here.
#![allow(clippy::significant_drop_tightening)]

use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, Instant};

use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use tempfile::TempDir;

const COLS: u16 = 100;
const ROWS: u16 = 30;
/// Tight, fail-fast wait. The self-hosted runner has little parallelism,
/// so anything slower than this is a genuine hang worth failing on.
const WAIT: Duration = Duration::from_secs(3);
const POLL: Duration = Duration::from_millis(20);

/// Tier-4 tests share one global lock so only one PTY + child runs at a
/// time (predictable timing; no resource races). Poison-tolerant so a
/// panicking test doesn't cascade-fail the rest.
static SERIAL: Mutex<()> = Mutex::new(());

fn lock_serial() -> MutexGuard<'static, ()> {
    SERIAL
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

/// A running instance of the app under a pseudo-terminal.
struct PtyApp {
    _serial: MutexGuard<'static, ()>,
    /// Present when this instance owns its data dir (single-launch
    /// flows); `None` when the test owns it (multi-launch flows that
    /// reopen the same dir).
    _owned_dir: Option<TempDir>,
    _master: Box<dyn MasterPty + Send>,
    writer: Box<dyn Write + Send>,
    parser: Arc<Mutex<vt100::Parser>>,
    child: Box<dyn portable_pty::Child + Send + Sync>,
    _reader: thread::JoinHandle<()>,
    /// Spawn → first rendered frame (the `SIMPLE` mode label). Measured
    /// for NFR-1; note this is the *debug* binary, so it's a generous
    /// gross-regression signal, not the release startup figure.
    startup: Duration,
}

impl PtyApp {
    /// Launch against a fresh, owned temp data dir.
    fn launch(args: &[&str]) -> Self {
        let dir = TempDir::new().expect("create temp data dir");
        let mut app = Self::launch_in(dir.path(), args);
        app._owned_dir = Some(dir);
        app
    }

    /// Launch against a caller-owned data dir (so a later launch can
    /// reopen the same projects — flow 2).
    fn launch_in(data_dir: &Path, args: &[&str]) -> Self {
        let serial = lock_serial();
        let pty = native_pty_system();
        let pair = pty
            .openpty(PtySize {
                rows: ROWS,
                cols: COLS,
                pixel_width: 0,
                pixel_height: 0,
            })
            .expect("open pty");

        let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_rdbms-playground"));
        cmd.arg("--data-dir");
        cmd.arg(data_dir);
        cmd.arg("--theme");
        cmd.arg("dark");
        for a in args {
            cmd.arg(a);
        }
        cmd.env("TERM", "xterm-256color");
        cmd.env("RDBMS_PLAYGROUND_DEMO", "0");
        cmd.cwd(data_dir);

        let started = Instant::now();
        let child = pair.slave.spawn_command(cmd).expect("spawn binary");
        drop(pair.slave); // we never write to the slave directly

        let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLS, 0)));
        let mut reader = pair.master.try_clone_reader().expect("clone pty reader");
        let writer = pair.master.take_writer().expect("take pty writer");

        let parser_for_reader = Arc::clone(&parser);
        let reader_handle = thread::spawn(move || {
            let mut buf = [0u8; 8192];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) | Err(_) => break,
                    Ok(n) => parser_for_reader
                        .lock()
                        .unwrap_or_else(std::sync::PoisonError::into_inner)
                        .process(&buf[..n]),
                }
            }
        });

        let mut app = Self {
            _serial: serial,
            _owned_dir: None,
            _master: pair.master,
            writer,
            parser,
            child,
            _reader: reader_handle,
            startup: Duration::ZERO,
        };
        // Every flow needs a booted, idle app; block on the first frame.
        app.wait_for("SIMPLE");
        app.startup = started.elapsed();
        app
    }

    /// Current visible screen as text (one line per row, trailing blanks
    /// trimmed) — what a human would read.
    fn screen_text(&self) -> String {
        self.parser
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .screen()
            .contents()
    }

    /// Text of just the left **Tables sidebar** region (first
    /// [`SIDEBAR_W`] columns). The Output panel echoes every command, so
    /// a table name typed in a command pollutes a whole-screen match —
    /// table presence/absence must be read from the sidebar, where the
    /// items list actually lives.
    fn sidebar(&self) -> String {
        const SIDEBAR_W: u16 = 28;
        let parser = self
            .parser
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let screen = parser.screen();
        let mut out = String::new();
        for row in 0..ROWS {
            for col in 0..SIDEBAR_W {
                if let Some(cell) = screen.cell(row, col) {
                    out.push_str(cell.contents());
                }
            }
            out.push('\n');
        }
        out
    }

    /// Poll a predicate to the tight timeout, panicking with a screen dump.
    fn wait_until(&self, desc: &str, pred: impl Fn(&Self) -> bool) {
        let deadline = Instant::now() + WAIT;
        loop {
            if pred(self) {
                return;
            }
            assert!(
                Instant::now() < deadline,
                "timed out after {WAIT:?} waiting for {desc}\n\
                 ----- screen -----\n{}\n------------------",
                self.screen_text(),
            );
            thread::sleep(POLL);
        }
    }

    /// Wait for `needle` anywhere on screen (Output-panel messages, modals).
    fn wait_for(&self, needle: &str) {
        self.wait_until(&format!("{needle:?} on screen"), |a| {
            a.screen_text().contains(needle)
        });
    }

    /// Wait for a table `name` to appear in the Tables sidebar.
    fn wait_for_table(&self, name: &str) {
        self.wait_until(&format!("table {name:?} in sidebar"), |a| {
            a.sidebar().contains(name)
        });
    }

    /// Wait for the Tables sidebar to be empty (the `(none yet)` placeholder).
    fn wait_for_no_tables(&self) {
        self.wait_until("empty Tables sidebar", |a| {
            a.sidebar().contains("(none yet)")
        });
    }

    fn send(&mut self, bytes: &[u8]) {
        self.writer.write_all(bytes).expect("write to pty");
        self.writer.flush().expect("flush pty");
    }

    /// Type a command and submit it (Enter == carriage return in raw mode).
    fn submit(&mut self, line: &str) {
        self.send(line.as_bytes());
        self.send(b"\r");
    }

    /// Resident set size of the child in KiB (Linux only). `process_id`
    /// is read inline here rather than via a helper so the method has no
    /// non-Linux callers to go dead (the CI clippy gate runs on Linux,
    /// where such dead code wouldn't surface).
    #[cfg(target_os = "linux")]
    fn rss_kib(&self) -> Option<u64> {
        let pid = self.child.process_id()?;
        let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
        status.lines().find_map(|l| {
            let rest = l.strip_prefix("VmRSS:")?;
            rest.split_whitespace().next()?.parse::<u64>().ok()
        })
    }

    /// Send Ctrl-C and assert the process exits cleanly within the wait.
    fn quit(mut self) {
        self.send(b"\x03");
        let deadline = Instant::now() + WAIT;
        loop {
            match self.child.try_wait() {
                Ok(Some(status)) => {
                    assert!(status.success(), "app exited unsuccessfully: {status:?}");
                    return;
                }
                Ok(None) => {}
                Err(e) => panic!("waiting on child failed: {e}"),
            }
            if Instant::now() >= deadline {
                let _ = self.child.kill();
                panic!(
                    "app did not exit within {WAIT:?} of Ctrl-C\n\
                     ----- screen -----\n{}\n------------------",
                    self.screen_text(),
                );
            }
            thread::sleep(POLL);
        }
    }
}

impl Drop for PtyApp {
    fn drop(&mut self) {
        // Never leak a child process, even if a test panicked mid-flow.
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

// ============================ the four flows ===========================

/// Flow 1 — cold launch → first DDL command → graceful quit.
#[test]
fn cold_launch_ddl_and_quit() {
    let mut app = PtyApp::launch(&[]);
    app.wait_for_no_tables(); // fresh project: empty sidebar
    app.submit("create table Customers with pk id(serial)");
    app.wait_for_table("Customers"); // table appears in the sidebar
    app.quit();
}

/// Flow 2 — create, quit, reopen the same project (`--resume`), identical
/// state. Persistence is per-command autosave; the kept temp is recorded
/// as the resume target on quit (ADR-0015).
#[test]
fn save_quit_and_reopen_restores_state() {
    let dir = TempDir::new().expect("shared data dir");

    let mut app = PtyApp::launch_in(dir.path(), &[]);
    app.submit("create table Customers with pk id(serial)");
    app.wait_for_table("Customers");
    app.submit("add column to Customers: Name (text)");
    app.wait_for("Name (text) ✓");
    app.quit();

    // Reopen the most-recently-used project from the same data root, then
    // assert the *column* survived — not just the table name — so "identical
    // state" means the schema, not merely that some table exists. In this
    // fresh process "Name" appears only from the show-data header.
    let mut reopened = PtyApp::launch_in(dir.path(), &["--resume"]);
    reopened.wait_for_table("Customers");
    reopened.submit("show data Customers");
    reopened.wait_for("Name"); // the added column round-tripped through restart
    reopened.quit();
}

/// Flow 3 — export a project, import it into a fresh project, confirm the
/// rebuilt database carries the schema and data.
#[test]
fn export_then_import_into_fresh_project() {
    let zip_dir = TempDir::new().expect("zip dir");
    let zip_path = zip_dir.path().join("export.zip");
    let zip = zip_path.to_str().expect("utf-8 zip path");

    // Source project: a table with one data row. `Name` is a regular
    // column (added separately) — putting it in the `with pk` clause would
    // make a compound PK and block the serial auto-fill.
    let mut source = PtyApp::launch(&[]);
    source.submit("create table Customers with pk id(serial)");
    source.wait_for_table("Customers");
    // Pace each command to completion before the next — driving a TUI, like
    // a real user (or the cast driver), means waiting for each result. A
    // command sent while the previous one's worker rebuild is still in
    // flight can be misread against a stale schema cache (issue #39).
    source.submit("add column to Customers: Name (text)");
    source.wait_for("Name (text) ✓");
    source.submit("insert into Customers values ('Alice')");
    // The insert's OWN success echo (value + ✓) — not a bare "✓", which the
    // create already painted — so export runs only once the row is in CSV.
    source.wait_for("('Alice') ✓");
    source.submit(&format!("export {zip}"));
    source.wait_for("[ok] export");
    source.quit();

    // Fresh project (new data dir): import the zip. Import switches to the
    // imported project, rebuilding its db from text since the export omits
    // the .db (ADR-0004/0015). "Alice" is unambiguous here — it appears in
    // no command typed into this instance, only in a rebuilt data row.
    let mut target = PtyApp::launch(&[]);
    target.submit(&format!("import {zip}"));
    target.wait_for("now editing"); // switched to the imported project
    target.wait_for_table("Customers"); // schema rebuilt
    target.submit("show data Customers");
    target.wait_for("Alice"); // data rebuilt from the CSV
    target.quit();
}

/// Flow 4 — `undo` immediately after `DROP TABLE`, including the
/// confirmation modal.
#[test]
fn undo_after_drop_table_restores_it() {
    let mut app = PtyApp::launch(&[]);
    app.submit("create table Customers with pk id(serial)");
    app.wait_for_table("Customers");

    app.submit("drop table Customers");
    app.wait_for_no_tables(); // gone from the sidebar

    app.submit("undo");
    app.wait_for("Restore that earlier state?"); // the confirm modal
    app.send(b"y"); // confirm
    app.wait_for_table("Customers"); // table restored in the sidebar
    app.quit();
}

/// Flow 5 — issue #39 regression: commands submitted **back-to-back**, with
/// no wait between them, must still execute correctly. This is the inverse of
/// flow 3 (which paces each command on purpose). Pre-fix, a Form-B insert sent
/// immediately after `add column` was validated against the *stale* schema
/// cache — the worker hadn't yet refreshed it — and wrongly rejected as
/// "trying to write SQL?", so the row never landed. The schema-refresh gate
/// (`App::awaiting_schema_refresh`) now holds each submission until the prior
/// command's refresh arrives, making the outcome independent of input speed.
#[test]
fn back_to_back_insert_after_ddl_still_succeeds() {
    let mut app = PtyApp::launch(&[]);
    app.wait_for_no_tables(); // fresh project

    // Fire all three with no readiness wait in between — the faster-than-human
    // input that triggered issue #39 (paste / script / unpaced driver).
    app.submit("create table Customers with pk id(serial)");
    app.submit("add column to Customers: Name (text)");
    app.submit("insert into Customers values ('Alice')");

    // The insert's OWN success echo (value + ✓) — proof the row reached the
    // database, not the "trying to write SQL?" rejection. If the gate
    // regressed, the insert would misparse against the stale schema and this
    // would time out.
    app.wait_for("('Alice') ✓");
    app.quit();
}

/// Flow 6 — ADR-0005 Amendment 2 backward-compat: opening a **legacy v1
/// project that declares a `blob` column** migrates it to `text` and opens
/// cleanly. This drives the real runtime's migrate-on-open + rebuild path
/// end-to-end — the post-removal binary would otherwise reject the v1/blob
/// project file. (The integration test covers the migrator + rebuild via
/// direct calls; this covers the runtime glue that decides to run them.)
#[test]
fn opens_a_legacy_v1_blob_project_by_migrating_to_text() {
    let dir = TempDir::new().expect("data dir");
    let root = dir.path();

    // Seed a v1 project (the old format) with a `blob` column, under the
    // data root's projects/ dir, and point `--resume` at it. No
    // playground.db is shipped, so the open rebuilds from the migrated text.
    let proj = root.join("projects").join("Legacy");
    fs::create_dir_all(proj.join("data")).expect("create project dirs");
    fs::write(
        proj.join("project.yaml"),
        concat!(
            "version: 1\n",
            "project:\n  created_at: 2026-01-01T00:00:00Z\n  mode: simple\n",
            "tables:\n",
            "  - name: Files\n",
            "    primary_key: [id]\n",
            "    columns:\n",
            "      - { name: id, type: serial }\n",
            "      - { name: payload, type: blob }\n",
            "relationships: []\n",
            "indexes: []\n",
        ),
    )
    .expect("write project.yaml");
    fs::write(
        proj.join("data").join("Files.csv"),
        "id,payload\n1,aGVsbG8=\n",
    )
    .expect("write csv");
    fs::write(root.join("last_project"), format!("{}\n", proj.display())).expect("write resume");

    // Open via --resume: the runtime migrates blob→text and rebuilds, so
    // the project opens and the table shows in the sidebar.
    let mut app = PtyApp::launch_in(root, &["--resume"]);
    app.wait_for_table("Files");
    // The former blob column is now a normal text column — `show data`
    // renders it (header + the base64 value preserved as text).
    app.submit("show data Files");
    app.wait_for("payload");
    app.quit();
}

// ===================== NFR perf (measured, generous) ===================
//
// These run against the DEBUG binary, so the bounds are loose
// gross-regression catches, not the NFR targets. The real NFR-1 (startup
// < 500 ms) and NFR-3 (idle RSS < 50 MB) figures are measured on a
// --release build and recorded in the NFR verification doc (ADR-0057).

/// NFR-1 — startup to first rendered frame. Generous debug-binary bound.
#[test]
fn startup_to_first_frame_is_reasonable() {
    let app = PtyApp::launch(&[]);
    let ms = app.startup.as_millis();
    eprintln!("NFR-1 startup (debug binary, under test harness): {ms} ms");
    assert!(
        app.startup < Duration::from_millis(2000),
        "startup {ms} ms exceeds the generous 2000 ms debug bound — likely a real regression",
    );
    app.quit();
}

/// NFR-3 — idle resident memory. Generous debug-binary bound (Linux only;
/// reads the child's /proc VmRSS).
#[cfg(target_os = "linux")]
#[test]
fn idle_memory_footprint_is_reasonable() {
    let app = PtyApp::launch(&[]);
    // Already idle after the readiness wait; let it settle a moment.
    thread::sleep(Duration::from_millis(200));
    let rss = app.rss_kib().expect("read VmRSS");
    eprintln!(
        "NFR-3 idle RSS (debug binary): {} KiB ({:.1} MB)",
        rss,
        rss as f64 / 1024.0,
    );
    assert!(
        rss < 150_000,
        "idle RSS {rss} KiB exceeds the generous 150 MB debug bound — likely a real regression",
    );
    app.quit();
}
