#!/usr/bin/env bash
# _quietlsp-shim.sh: resolves the real language server past its own shim dir
# (including when the shim dir is reached via a symlink, like the real
# ~/.claude/bin -> deploy-clone layout), skips PATH candidates that fail a
# `--version` validation probe (e.g. a rustup proxy stub for an uninstalled
# toolchain component), and fails open to the real server when the quietlsp
# repo/wrapper/node is unavailable.
set -uo pipefail
TMP=$(mktemp -d "$HOME/.cache/quietlsp-shim-test-XXXXXX")
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
trap 'rm -rf "$TMP"' EXIT

REAL_SHIM_DIR="$TMP/real-bin"
SHIM_DIR="$TMP/home/.claude/bin"
mkdir -p "$REAL_SHIM_DIR" "$TMP/lib" "$TMP/sysbin" "$TMP/sysbin-nonode" "$TMP/repo" "$TMP/home/.claude" "$TMP/home/.local/state/overdeck"
ln -s "$REAL_SHIM_DIR" "$SHIM_DIR"

# Coreutils-only PATH dir (dirname/readlink/date/mkdir/timeout) for tests that
# must NOT see this real machine's own /usr/bin/rust-analyzer — that binary
# is genuinely functional here and would silently validate, masking the
# "nothing validates" case this test exists to cover.
CORE_DIR="$TMP/coreutils"
mkdir -p "$CORE_DIR"
for t in dirname readlink date mkdir timeout cat wc bash env; do
  real_t="$(command -v "$t")"
  ln -s "$real_t" "$CORE_DIR/$t"
done
cat >"$CORE_DIR/node" <<'EOF'
#!/usr/bin/env bash
echo "NODE $*"
EOF
chmod +x "$CORE_DIR/node"

cp "$SCRIPT_DIR/../bin/_quietlsp-shim.sh" "$REAL_SHIM_DIR/_quietlsp-shim.sh"
cp "$SCRIPT_DIR/../lib/shim-guard.sh" "$TMP/lib/shim-guard.sh"
chmod +x "$REAL_SHIM_DIR/_quietlsp-shim.sh"
ln -s _quietlsp-shim.sh "$REAL_SHIM_DIR/typescript-language-server"
ln -s _quietlsp-shim.sh "$REAL_SHIM_DIR/intelephense"
ln -s _quietlsp-shim.sh "$REAL_SHIM_DIR/rust-analyzer"

cat >"$TMP/sysbin/typescript-language-server" <<'EOF'
#!/usr/bin/env bash
echo "REAL $*"
EOF
chmod +x "$TMP/sysbin/typescript-language-server"
cp "$TMP/sysbin/typescript-language-server" "$TMP/sysbin/intelephense"
cp "$TMP/sysbin/typescript-language-server" "$TMP/sysbin-nonode/typescript-language-server"

cat >"$TMP/repo/quietlsp" <<'EOF'
#!/usr/bin/env bash
echo "QUIETLSP $*"
EOF
chmod +x "$TMP/repo/quietlsp"

cat >"$TMP/sysbin/node" <<'EOF'
#!/usr/bin/env bash
echo "NODE $*"
EOF
chmod +x "$TMP/sysbin/node"

PASS=0; FAIL=0
t() { local name="$1" want="$2" got="$3"; if [[ "$got" == $want ]]; then echo "PASS $name"; PASS=$((PASS+1)); else echo "FAIL $name want=$want got=$got"; FAIL=$((FAIL+1)); fi; }

run() {
  env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/sysbin:/usr/bin:/bin" \
    OD_QUIETLSP_REPO="$TMP/repo" "$@" \
    "$SHIM_DIR/typescript-language-server" --stdio
}

# Wrapper + node present: routes through quietlsp with the resolved real path,
# proving resolution correctly skipped past the shim dir even though it was
# reached via a symlink (readlink -f normalizes both sides).
t "routes-through-quietlsp-when-available" "NODE $TMP/repo/quietlsp $TMP/sysbin/typescript-language-server --stdio" "$(run)"
t "intelephense-routes-through-quietlsp" "NODE $TMP/repo/quietlsp $TMP/sysbin/intelephense --stdio" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo" "$SHIM_DIR/intelephense" --stdio)"

# Wrapper missing: fails open to the real binary, one log line written.
rm -f "$TMP/home/.local/state/overdeck/quietlsp.log"
t "fails-open-when-wrapper-missing" "REAL --stdio" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo-missing" "$SHIM_DIR/typescript-language-server" --stdio)"
t "fail-open-logs-one-line" "1" "$(wc -l <"$TMP/home/.local/state/overdeck/quietlsp.log" 2>/dev/null || echo 0)"

# Wrapper present but node unresolvable: fails open to the real binary. Forced
# via an explicit override to a non-executable path rather than trimming PATH,
# since the shim's own coreutils (dirname/readlink/date) still need a real PATH.
t "fails-open-when-node-missing" "REAL --stdio" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo" OD_QUIETLSP_NODE="$TMP/sysbin-nonode/no-such-node" "$SHIM_DIR/typescript-language-server" --stdio)"

# Self-exclusion: a second shim earlier on PATH must not be resolved as "real".
mkdir -p "$TMP/decoy-bin"
ln -s "$REAL_SHIM_DIR/_quietlsp-shim.sh" "$TMP/decoy-bin/typescript-language-server"
t "skips-a-second-shim-copy-on-path" "NODE $TMP/repo/quietlsp $TMP/sysbin/typescript-language-server --stdio" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/decoy-bin:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo" "$SHIM_DIR/typescript-language-server" --stdio)"

# Proxy-skip: a rustup-style proxy sits earlier on PATH than the real binary.
# It is executable, not a shim, and --version exits nonzero (mimics
# ~/.cargo/bin/rust-analyzer with no matching toolchain component) — the
# resolver must skip it and keep scanning to the real binary behind it.
PROXY_DIR="$TMP/proxybin"
REALRA_DIR="$TMP/realra"
mkdir -p "$PROXY_DIR" "$REALRA_DIR"
cat >"$PROXY_DIR/rust-analyzer" <<'EOF'
#!/usr/bin/env bash
echo "info: \`rust-analyzer\` is unavailable for the active toolchain" >&2
exit 1
EOF
chmod +x "$PROXY_DIR/rust-analyzer"
cat >"$REALRA_DIR/rust-analyzer" <<'EOF'
#!/usr/bin/env bash
if [ "${1:-}" = "--version" ]; then echo "rust-analyzer 1.95.0"; exit 0; fi
echo "REAL-RA $*"
EOF
chmod +x "$REALRA_DIR/rust-analyzer"
t "skips-proxy-that-fails-version-and-finds-real-binary" "NODE $TMP/repo/quietlsp $REALRA_DIR/rust-analyzer" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$PROXY_DIR:$REALRA_DIR:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo" "$SHIM_DIR/rust-analyzer")"

# All candidates fail --version: the resolved "real" is still the first
# candidate seen (fail-open — a broken server surfacing its own error beats
# silently vanishing) and routing proceeds normally through quietlsp with it,
# logging exactly one line naming that no candidate validated. Deliberately
# excludes this machine's real /usr/bin — only the coreutils shims (incl. a
# fake node) + the failing proxy are on PATH, so nothing else can validate.
rm -f "$TMP/home/.local/state/overdeck/quietlsp.log"
t "falls-open-to-first-candidate-when-none-validate" "NODE $TMP/repo/quietlsp $PROXY_DIR/rust-analyzer" \
  "$(env -i HOME="$TMP/home" PATH="$SHIM_DIR:$PROXY_DIR:$CORE_DIR" OD_QUIETLSP_REPO="$TMP/repo" "$SHIM_DIR/rust-analyzer")"
t "no-validated-candidate-logs-one-line" "1" "$(wc -l <"$TMP/home/.local/state/overdeck/quietlsp.log" 2>/dev/null || echo 0)"

# rust-analyzer routes through quietlsp exactly like typescript-language-server
# (both symlinks share the same _quietlsp-shim.sh body).
t "rust-analyzer-routes-through-quietlsp" "NODE $TMP/repo/quietlsp $TMP/sysbin/rust-analyzer" \
  "$(cat >"$TMP/sysbin/rust-analyzer" <<'EOF'
#!/usr/bin/env bash
if [ "${1:-}" = "--version" ]; then echo "rust-analyzer 1.95.0"; exit 0; fi
echo "REAL $*"
EOF
chmod +x "$TMP/sysbin/rust-analyzer"
env -i HOME="$TMP/home" PATH="$SHIM_DIR:$TMP/sysbin:/usr/bin:/bin" OD_QUIETLSP_REPO="$TMP/repo" "$SHIM_DIR/rust-analyzer")"

# ---------------------------------------------------------------------------
# End-to-end through the REAL quietlsp wrapper (product repo, outside this
# repo). Covers the spec's one sanctioned client->server mutation (R1
# capability rewrite of `initialize`, Content-Length recomputed for that frame
# only) and the server->client publishDiagnostics scope filter, exercised
# through this repo's shim exactly as a session would reach it.
#
# The filter body itself lives in /home/user/Projects/quietlsp (`quietlsp`,
# tested there); what this asserts is that the shim hands the stream to it
# intact and that the composed pipeline behaves per docs/SPEC.md. Absent
# product repo or node => explicit SKIP, never a failure: the shim's whole
# contract is to fail open when that repo is missing, and CI boxes will not
# have it.
QUIETLSP_REPO_REAL="${OD_QUIETLSP_REPO_REAL:-$HOME/Projects/quietlsp}"
NODE_REAL="$(command -v node 2>/dev/null || true)"
if [[ ! -x "$QUIETLSP_REPO_REAL/quietlsp" || -z "$NODE_REAL" ]]; then
  echo "SKIP end-to-end-quietlsp-filter (no executable $QUIETLSP_REPO_REAL/quietlsp or no node)"
else
  E2E="$TMP/e2e"
  mkdir -p "$E2E/tree" "$E2E/other" "$E2E/srvbin"

  # Fake "real language server": records every raw byte the client side sends
  # it (so we can assert byte-exactness of client->server frames), and once it
  # has seen two complete frames emits one in-tree and one out-of-tree
  # publishDiagnostics, then exits.
  cat >"$E2E/srvbin/typescript-language-server" <<'EOF'
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
if (process.argv.includes('--version')) { console.log('fake-tsserver 0.0.0'); process.exit(0); }
const rec = process.env.QLSP_RECORD;
const inTree = process.env.QLSP_IN_URI;
const outTree = process.env.QLSP_OUT_URI;
let buf = Buffer.alloc(0);
let done = false;
function frames(b) {
  let n = 0, off = 0;
  for (;;) {
    const hdrEnd = b.indexOf('\r\n\r\n', off);
    if (hdrEnd < 0) return n;
    const hdr = b.slice(off, hdrEnd).toString('ascii');
    const m = /Content-Length:\s*(\d+)/i.exec(hdr);
    if (!m) return n;
    const bodyStart = hdrEnd + 4;
    const bodyEnd = bodyStart + Number(m[1]);
    if (b.length < bodyEnd) return n;
    n += 1; off = bodyEnd;
  }
}
function frame(obj) {
  const body = Buffer.from(JSON.stringify(obj), 'utf8');
  return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]);
}
function pub(uri, message) {
  return frame({ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics',
    params: { uri, version: 1, diagnostics: [{ message, severity: 1,
      range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }] } });
}
process.stdin.on('data', (c) => {
  fs.appendFileSync(rec, c);
  buf = Buffer.concat([buf, c]);
  if (done || frames(buf) < 2) return;
  done = true;
  process.stdout.write(Buffer.concat([pub(inTree, 'IN-TREE'), pub(outTree, 'OUT-OF-TREE')]), () => {
    setTimeout(() => process.exit(0), 50);
  });
});
EOF
  chmod +x "$E2E/srvbin/typescript-language-server"

  # Driver: speaks LSP framing at the shim, collects what comes back, and
  # asserts. Exact byte equality throughout — deliberately NOT the `t` helper
  # above, whose unquoted `==` RHS is a glob pattern and would treat JSON
  # brackets as character classes.
  cat >"$E2E/driver.cjs" <<'EOF'
'use strict';
const { spawn } = require('node:child_process');
const fs = require('node:fs');

const [shim, root, record, inUri, outUri, home, repo, srvbin] = process.argv.slice(2);
let pass = 0, fail = 0;
function ok(name, cond, detail) {
  if (cond) { console.log(`PASS ${name}`); pass += 1; }
  else { console.log(`FAIL ${name} ${detail || ''}`); fail += 1; }
}
function frame(obj) {
  const body = Buffer.from(JSON.stringify(obj), 'utf8');
  return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]);
}
function splitFrames(b) {
  const out = [];
  let off = 0;
  for (;;) {
    const hdrEnd = b.indexOf('\r\n\r\n', off);
    if (hdrEnd < 0) return out;
    const header = b.slice(off, hdrEnd).toString('ascii');
    const m = /Content-Length:\s*(\d+)/i.exec(header);
    if (!m) return out;
    const bodyStart = hdrEnd + 4;
    const bodyEnd = bodyStart + Number(m[1]);
    if (b.length < bodyEnd) return out;
    out.push({ header, declared: Number(m[1]), body: b.slice(bodyStart, bodyEnd),
      whole: b.slice(off, bodyEnd) });
    off = bodyEnd;
  }
}

const initMsg = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {
  processId: 1, rootUri: `file://${root}`,
  capabilities: { textDocument: {
    diagnostic: { dynamicRegistration: true, relatedDocumentSupport: false },
    publishDiagnostics: { relatedInformation: true },
    hover: { dynamicRegistration: false } } } } };
const secondMsg = { jsonrpc: '2.0', id: 2, method: 'textDocument/hover',
  params: { textDocument: { uri: `file://${root}/in.ts` }, position: { line: 0, character: 0 } } };
const initFrame = frame(initMsg);
const secondFrame = frame(secondMsg);

const child = spawn(shim, ['--stdio'], {
  cwd: root,
  stdio: ['pipe', 'pipe', 'inherit'],
  env: { HOME: home, PATH: `${shim.replace(/\/[^/]+$/, '')}:${srvbin}:/usr/bin:/bin`,
    OD_QUIETLSP_REPO: repo, QLSP_RECORD: record, QLSP_IN_URI: inUri, QLSP_OUT_URI: outUri },
});
let out = Buffer.alloc(0);
child.stdout.on('data', (c) => { out = Buffer.concat([out, c]); });
child.on('close', () => {
  const sent = splitFrames(fs.readFileSync(record));
  ok('e2e-server-received-two-client-frames', sent.length === 2, `got=${sent.length}`);
  if (sent.length === 2) {
    const gotInit = JSON.parse(sent[0].body.toString('utf8'));
    const td = gotInit.params.capabilities.textDocument;
    ok('e2e-initialize-diagnostic-capability-stripped',
      !Object.prototype.hasOwnProperty.call(td, 'diagnostic'), JSON.stringify(td));
    ok('e2e-initialize-other-capabilities-preserved',
      JSON.stringify(td.publishDiagnostics) === JSON.stringify(initMsg.params.capabilities.textDocument.publishDiagnostics)
      && JSON.stringify(td.hover) === '{"dynamicRegistration":false}', JSON.stringify(td));
    ok('e2e-initialize-content-length-recomputed',
      sent[0].declared === sent[0].body.length && sent[0].declared !== initFrame.length - (initFrame.indexOf('\r\n\r\n') + 4),
      `declared=${sent[0].declared} body=${sent[0].body.length}`);
    ok('e2e-non-initialize-client-frame-byte-exact',
      sent[1].whole.equals(secondFrame),
      JSON.stringify(sent[1].whole.toString('utf8')));
  }
  const back = splitFrames(out);
  const bodies = back.map((f) => f.body.toString('utf8'));
  ok('e2e-out-of-tree-publish-dropped',
    !bodies.some((b) => b.includes('OUT-OF-TREE')), bodies.join('|'));
  const inFrame = back.find((f) => f.body.toString('utf8').includes('IN-TREE'));
  ok('e2e-in-tree-publish-forwarded', Boolean(inFrame), bodies.join('|'));
  if (inFrame) {
    const expectBody = JSON.stringify({ jsonrpc: '2.0', method: 'textDocument/publishDiagnostics',
      params: { uri: inUri, version: 1, diagnostics: [{ message: 'IN-TREE', severity: 1,
        range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }] } });
    ok('e2e-in-tree-publish-byte-exact', inFrame.body.toString('utf8') === expectBody,
      inFrame.body.toString('utf8'));
  }
  console.log(`e2e passed=${pass} failed=${fail}`);
  process.exit(fail === 0 ? 0 : 1);
});
child.stdin.write(initFrame);
child.stdin.write(secondFrame);
EOF

  : >"$E2E/record.bin"
  E2E_ROOT="$(readlink -f "$E2E/tree")"
  E2E_OUT="$(readlink -f "$E2E/other")"
  e2e_out="$(node "$E2E/driver.cjs" "$SHIM_DIR/typescript-language-server" "$E2E_ROOT" \
    "$E2E/record.bin" "file://$E2E_ROOT/in.ts" "file://$E2E_OUT/out.ts" \
    "$TMP/home" "$QUIETLSP_REPO_REAL" "$E2E/srvbin" 2>&1)" || true
  echo "$e2e_out"
  e2e_pass="$(printf '%s\n' "$e2e_out" | grep -c '^PASS ' || true)"
  e2e_fail="$(printf '%s\n' "$e2e_out" | grep -c '^FAIL ' || true)"
  PASS=$((PASS + e2e_pass))
  FAIL=$((FAIL + e2e_fail))
  [ "$e2e_pass" -ge 7 ] || { echo "FAIL e2e-suite-incomplete expected>=7 got=$e2e_pass"; FAIL=$((FAIL+1)); }
fi

echo "passed=$PASS failed=$FAIL"
[ "$FAIL" -eq 0 ]
