import { describe, expect, it } from "vitest";
import type { TSNode } from "#src/access-intent/bash/parser";
import { getParser } from "#src/access-intent/bash/parser";
import {
  collectCommandTokens,
  collectPathCandidateTokens,
  collectRedirectTokens,
  extractCommandName,
} from "#src/access-intent/bash/token-collection";

// ── Helpers ───────────────────────────────────────────────────────────────────

/** Depth-first search for the first node of the given type. */
function findNode(node: TSNode, type: string): TSNode | null {
  if (node.type === type) return node;
  for (let i = 0; i < node.childCount; i++) {
    const child = node.child(i);
    if (!child) continue;
    const found = findNode(child, type);
    if (found) return found;
  }
  return null;
}

/** Parse a bash snippet and return the first `command` node. */
async function parseCommandNode(cmd: string): Promise<{
  node: TSNode;
  tree: { rootNode: TSNode; delete(): void };
}> {
  const parser = await getParser();
  const tree = parser.parse(cmd);
  if (!tree) throw new Error("parser.parse returned null");
  const node = findNode(tree.rootNode, "command");
  if (!node) throw new Error(`no command node found in: ${cmd}`);
  return { node, tree };
}

/** Parse a bash snippet and return the first `file_redirect` node. */
async function parseRedirectNode(cmd: string): Promise<{
  node: TSNode;
  tree: { rootNode: TSNode; delete(): void };
}> {
  const parser = await getParser();
  const tree = parser.parse(cmd);
  if (!tree) throw new Error("parser.parse returned null");
  const node = findNode(tree.rootNode, "file_redirect");
  if (!node) throw new Error(`no file_redirect node found in: ${cmd}`);
  return { node, tree };
}

// ── extractCommandName ────────────────────────────────────────────────────────

describe("extractCommandName", () => {
  it("returns the basename for a bare command", async () => {
    const { node, tree } = await parseCommandNode("sed 's/x/y/' file.txt");
    try {
      expect(extractCommandName(node)).toBe("sed");
    } finally {
      tree.delete();
    }
  });

  it("strips the directory prefix from an absolute command path", async () => {
    const { node, tree } = await parseCommandNode(
      "/usr/bin/sed 's/x/y/' file.txt",
    );
    try {
      expect(extractCommandName(node)).toBe("sed");
    } finally {
      tree.delete();
    }
  });

  it("returns the substitution text when the command name is a command substitution", async () => {
    // $(which sed) parses with a command_name child whose text is "$(which sed)";
    // resolveNodeText returns that text, so extractCommandName returns its basename.
    // PATTERN_FIRST_COMMANDS.get("$(which sed)") returns undefined, so
    // collectCommandTokens falls back to generic collection — correct behaviour.
    const { node, tree } = await parseCommandNode(
      "$(which sed) 's/x/y/' file.txt",
    );
    try {
      expect(extractCommandName(node)).toBe("$(which sed)");
    } finally {
      tree.delete();
    }
  });
});

// ── collectCommandTokens — pattern-first commands ─────────────────────────────

describe("collectCommandTokens — pattern-first commands", () => {
  it("sed: skips the first positional (inline pattern) and collects the rest", async () => {
    const { node, tree } = await parseCommandNode("sed 's/x/y/' a.txt b.txt");
    try {
      expect(collectCommandTokens(node)).toEqual(["a.txt", "b.txt"]);
    } finally {
      tree.delete();
    }
  });

  it("sed -e: skips the explicit script arg-consuming flag and collects positionals", async () => {
    const { node, tree } = await parseCommandNode("sed -e 's/x/y/' file.txt");
    try {
      // -e consumes the next argument (the script), so file.txt is the first positional
      // Since hasExplicitScript is set by -e, the positional is not skipped
      expect(collectCommandTokens(node)).toEqual(["file.txt"]);
    } finally {
      tree.delete();
    }
  });

  it("sed -f: treats the next argument as a file path (file-consuming flag)", async () => {
    const { node, tree } = await parseCommandNode(
      "sed -f /scripts/script.sed file.txt",
    );
    try {
      // -f consumes the next arg as a file path (extracted), and sets hasExplicitScript
      expect(collectCommandTokens(node)).toEqual([
        "/scripts/script.sed",
        "file.txt",
      ]);
    } finally {
      tree.delete();
    }
  });

  it("grep: skips the first positional (pattern) and collects file arguments", async () => {
    const { node, tree } = await parseCommandNode(
      "grep pattern /etc/hosts /etc/passwd",
    );
    try {
      expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
    } finally {
      tree.delete();
    }
  });

  it("grep -e: with explicit -e flag, all positionals are file arguments", async () => {
    const { node, tree } = await parseCommandNode("grep -e pattern /etc/hosts");
    try {
      expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
    } finally {
      tree.delete();
    }
  });

  it("grep: end-of-flags (--) causes subsequent args to be treated as positionals", async () => {
    const { node, tree } = await parseCommandNode("grep -- pattern /etc/hosts");
    try {
      // After --, both 'pattern' (first positional) and '/etc/hosts' are positionals.
      // pattern is the pattern positional and is skipped; /etc/hosts is collected.
      expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
    } finally {
      tree.delete();
    }
  });

  it("sd: skips the first two positionals (FIND and REPLACE_WITH) as patterns", async () => {
    const { node, tree } = await parseCommandNode(
      "sd find replace file.txt other.txt",
    );
    try {
      expect(collectCommandTokens(node)).toEqual(["file.txt", "other.txt"]);
    } finally {
      tree.delete();
    }
  });

  it("rg: skips the pattern positional and collects file/dir arguments", async () => {
    const { node, tree } = await parseCommandNode("rg pattern /etc/");
    try {
      expect(collectCommandTokens(node)).toEqual(["/etc/"]);
    } finally {
      tree.delete();
    }
  });
});

// ── collectCommandTokens — generic commands ───────────────────────────────────

describe("collectCommandTokens — generic commands", () => {
  it("collects all argument tokens after the command name", async () => {
    const { node, tree } = await parseCommandNode("cat /etc/hosts /etc/passwd");
    try {
      expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
    } finally {
      tree.delete();
    }
  });

  it("skips variable assignment prefixes", async () => {
    const { node, tree } = await parseCommandNode("FOO=/bar cat /etc/hosts");
    try {
      expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
    } finally {
      tree.delete();
    }
  });

  it("collects no tokens for a bare command with no arguments", async () => {
    const { node, tree } = await parseCommandNode("ls");
    try {
      expect(collectCommandTokens(node)).toEqual([]);
    } finally {
      tree.delete();
    }
  });
});

// ── collectRedirectTokens ─────────────────────────────────────────────────────

describe("collectRedirectTokens", () => {
  it("collects the destination path from a stdout redirect", async () => {
    const { node, tree } = await parseRedirectNode(
      "cat /etc/hosts > /tmp/out.txt",
    );
    try {
      expect(collectRedirectTokens(node)).toEqual(["/tmp/out.txt"]);
    } finally {
      tree.delete();
    }
  });

  it("collects the destination path from an append redirect", async () => {
    const { node, tree } = await parseRedirectNode(
      "echo hello >> /tmp/log.txt",
    );
    try {
      expect(collectRedirectTokens(node)).toEqual(["/tmp/log.txt"]);
    } finally {
      tree.delete();
    }
  });

  it("collects the source path from a stdin redirect", async () => {
    const { node, tree } = await parseRedirectNode("cat < /etc/hosts");
    try {
      expect(collectRedirectTokens(node)).toEqual(["/etc/hosts"]);
    } finally {
      tree.delete();
    }
  });

  describe("operands of a hosted nested command (#741)", () => {
    it("collects the operand of a substitution used as the destination", async () => {
      const { node, tree } = await parseRedirectNode(
        "echo hi > $(cat /etc/shadow)",
      );
      try {
        expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
      } finally {
        tree.delete();
      }
    });

    it("collects the operand of a process substitution read as input", async () => {
      const { node, tree } = await parseRedirectNode(
        "cat < <(cat /etc/shadow)",
      );
      try {
        expect(collectRedirectTokens(node)).toEqual(["/etc/shadow"]);
      } finally {
        tree.delete();
      }
    });

    it("collects both the destination text and a concatenated operand", async () => {
      const { node, tree } = await parseRedirectNode(
        "echo hi > /tmp/$(cat /etc/shadow)",
      );
      try {
        expect(collectRedirectTokens(node)).toEqual([
          "/tmp/$(cat /etc/shadow)",
          "/etc/shadow",
        ]);
      } finally {
        tree.delete();
      }
    });
  });
});

// ── collectPathCandidateTokens ────────────────────────────────────────────────

describe("collectPathCandidateTokens", () => {
  it("collects all argument tokens from a simple command via the program root", async () => {
    const parser = await getParser();
    const tree = parser.parse("cat /etc/hosts");
    try {
      if (!tree) throw new Error("parse returned null");
      expect(collectPathCandidateTokens(tree.rootNode)).toEqual(["/etc/hosts"]);
    } finally {
      tree?.delete();
    }
  });

  it("collects redirect destinations as well as command arguments", async () => {
    const parser = await getParser();
    const tree = parser.parse("cat /etc/hosts > /tmp/out.txt");
    try {
      if (!tree) throw new Error("parse returned null");
      expect(collectPathCandidateTokens(tree.rootNode)).toEqual([
        "/etc/hosts",
        "/tmp/out.txt",
      ]);
    } finally {
      tree?.delete();
    }
  });

  it("returns empty array for heredoc-only content (SKIP_SUBTREE_TYPES)", async () => {
    const parser = await getParser();
    const tree = parser.parse("cat <<EOF\nhello\nEOF");
    try {
      if (!tree) throw new Error("parse returned null");
      // heredoc_body is in SKIP_SUBTREE_TYPES — its text must not be collected
      const tokens = collectPathCandidateTokens(tree.rootNode);
      expect(tokens).not.toContain("hello");
    } finally {
      tree?.delete();
    }
  });

  describe("operands hosted in a heredoc body (#741)", () => {
    async function collectFrom(command: string): Promise<string[]> {
      const parser = await getParser();
      const tree = parser.parse(command);
      if (!tree) throw new Error("parse returned null");
      try {
        return collectPathCandidateTokens(tree.rootNode);
      } finally {
        tree.delete();
      }
    }

    it("collects the operand of an interpolating heredoc body", async () => {
      expect(await collectFrom("cat <<EOF\n$(cat /etc/shadow)\nEOF")).toEqual([
        "/etc/shadow",
      ]);
    });

    it.each([
      ["single-quoted", "cat <<'EOF'\n$(cat /etc/shadow)\nEOF"],
      ["double-quoted", 'cat <<"EOF"\n$(cat /etc/shadow)\nEOF'],
    ])("collects nothing from a %s heredoc body", async (_label, command) => {
      expect(await collectFrom(command)).toEqual([]);
    });

    it("never collects heredoc prose, even alongside a substitution", async () => {
      expect(
        await collectFrom(
          "cat <<EOF\n/etc/passwd is prose\n$(cat /etc/shadow)\nEOF",
        ),
      ).toEqual(["/etc/shadow"]);
    });

    it("collects the operand of a herestring substitution", async () => {
      expect(await collectFrom("cat <<< $(cat /etc/shadow)")).toEqual([
        "/etc/shadow",
      ]);
    });
  });

  it("recurses into command substitution to collect nested tokens", async () => {
    const parser = await getParser();
    const tree = parser.parse("cat $(echo /etc/hosts)");
    try {
      if (!tree) throw new Error("parse returned null");
      // The command_substitution is a non-command, non-redirect node — recurse
      const tokens = collectPathCandidateTokens(tree.rootNode);
      // /etc/hosts is inside the substitution, collected by recursion
      expect(tokens).toContain("/etc/hosts");
    } finally {
      tree?.delete();
    }
  });
});

describe("embedded --opt=value extraction (#645)", () => {
  async function tokensOf(cmd: string): Promise<string[]> {
    const { node, tree } = await parseCommandNode(cmd);
    try {
      return collectCommandTokens(node);
    } finally {
      tree.delete();
    }
  }

  it("emits the value of a long option carrying an inline path", async () => {
    // The issue's second repro: the flag token itself is rejected by the
    // shape prelude, so the embedded path had to be split out to be seen.
    expect(await tokensOf("grep --file=/tmp/patterns target")).toContain(
      "/tmp/patterns",
    );
  });

  it("emits the embedded value for a non-pattern-first command too", async () => {
    expect(await tokensOf("tar --directory=/etc -xf a.tar")).toContain("/etc");
  });

  it("preserves the original flag token", async () => {
    expect(await tokensOf("cat --file=/tmp/x")).toContain("--file=/tmp/x");
  });

  it("emits a bare value, leaving it for the shape gates to drop", async () => {
    // --format=json yields "json", which names nothing and is dropped later.
    expect(await tokensOf("cat --format=json")).toContain("json");
  });

  it("splits the single-dash form", async () => {
    expect(await tokensOf("cat -o=/tmp/out")).toContain("/tmp/out");
  });

  it("does not split a flag with no value", async () => {
    const tokens = await tokensOf("grep --recursive target");
    expect(tokens).not.toContain("");
    expect(tokens).not.toContain("--recursive");
  });

  it("does not split a non-flag token containing '='", async () => {
    // FOO=bar is a variable_assignment, never an argument token.
    expect(await tokensOf("cat a=b")).toEqual(["a=b"]);
  });

  it("keeps only the first '=' as the separator", async () => {
    expect(await tokensOf("cat --opt=/tmp/a=b")).toContain("/tmp/a=b");
  });
});
