// SPDX-License-Identifier: LGPL-2.1-or-later
import cockpit from "cockpit";
import QUnit from "qunit-tests";

let dir = "/uninitialized";

QUnit.test("simple read", async assert => {
    const file = cockpit.file(dir + "/foo");
    assert.equal(file.path, dir + "/foo", "file has path");
    assert.equal(await file.read(), "1234\n", "correct result");
});

QUnit.test("read non-existent", async assert => {
    assert.equal(await cockpit.file(dir + "/blah").read(), null, "correct result");
});

QUnit.test("parse read", async assert => {
    const resp = await cockpit.file(dir + "/foo.json", { syntax: JSON }).read();
    assert.deepEqual(resp, { foo: 12 }, "correct result");
});

QUnit.test("parse read error", async assert => {
    try {
        await cockpit.file(dir + "/foo.bin", { syntax: JSON }).read();
        assert.ok(false, "should have failed");
    } catch (error) {
        assert.ok(error instanceof SyntaxError, "got SyntaxError error");
    }
});

QUnit.test("binary read", async assert => {
    const resp = await cockpit.file(dir + "/foo.bin", { binary: true }).read();
    assert.deepEqual(resp, new Uint8Array([0, 1, 2, 3]), "correct result");
});

QUnit.test("read non-regular", async assert => {
    try {
        await cockpit.file(dir, { binary: true }).read();
        assert.ok(false, "should have failed");
    } catch (error) {
        assert.equal((error as cockpit.BasicError).problem, "internal-error", "got error");
    }
});

QUnit.test("read large", async assert => {
    const resp = await cockpit.file(dir + "/large.bin", { binary: true }).read();
    assert.equal(resp.length, 512 * 1024, "correct result");
});

QUnit.test("read too large", async assert => {
    try {
        await cockpit.file(dir + "/large.bin", { binary: true, max_read_size: 8 * 1024 }).read();
        assert.ok(false, "should have failed");
    } catch (error) {
        assert.equal((error as cockpit.BasicError).problem, "too-large", "got error");
    }
});

/* regression: passing 'binary: false' made cockpit-ws close the whole connection */
QUnit.test("binary false", async assert => {
    await cockpit.file(dir + "/foo.bin", { binary: false }).read();
    assert.ok(true, "did not crash");
});

QUnit.test("simple replace", async assert => {
    await cockpit.file(dir + "/bar").replace("4321\n");
    const res = await cockpit.spawn(["cat", dir + "/bar"]);
    assert.equal(res, "4321\n", "correct content");
});

QUnit.test("empty replace", async assert => {
    await cockpit.file(`${dir}/bar`).replace("");
    const res = await cockpit.spawn(["cat", `${dir}/bar`]);
    assert.equal(res, "", "correct content");
});

QUnit.test("stringify replace", async assert => {
    await cockpit.file(dir + "/bar", { syntax: JSON }).replace({ foo: 4321 });
    const res = await cockpit.spawn(["cat", dir + "/bar"]);
    assert.deepEqual(JSON.parse(res), { foo: 4321 }, "correct content");
});

QUnit.test("stringify replace error", async assert => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const cycle: any = { };
    cycle.me = cycle;
    try {
        await cockpit.file(dir + "/bar", { syntax: JSON }).replace(cycle);
        assert.ok(false, "should have failed");
    } catch (error) {
        assert.ok(error instanceof TypeError, "got stringify error");
    }
});

QUnit.test("binary replace", async assert => {
    await cockpit.file(dir + "/bar", { binary: true }).replace(new Uint8Array([3, 2, 1, 0]));
    const res = await cockpit.spawn(["cat", dir + "/bar"], { binary: true });
    assert.deepEqual(res, new Uint8Array([3, 2, 1, 0]), "correct content");
});

QUnit.test("replace large", async assert => {
    const str = new Array(23 * 1023).join('abcdef12345');
    await cockpit.file(dir + "/large").replace(str);
    const res = await cockpit.spawn(["cat", dir + "/large"]);
    assert.equal(res.length, str.length, "correct large length");
    assert.ok(res == str, "correct large data");
});

QUnit.test("binary replace large", async assert => {
    const data = new Uint8Array(249 * 1023);
    const len = data.byteLength;
    for (let i = 0; i < len; i++)
        data[i] = i % 233;
    await cockpit.file(dir + "/large-binary", { binary: true }).replace(data);
    const res = await cockpit.spawn(["cat", dir + "/large-binary"], { binary: true });
    let eq = true;
    assert.equal(res.byteLength, 249 * 1023, "check length");
    assert.equal(res.byteLength, data.byteLength, "correct large length");
    for (let i = 0; i < res.byteLength; i++) {
        if (res[i] !== data[i] || res[i] === undefined) {
            eq = false;
            break;
        }
    }
    assert.ok(eq, "got back same data");
});

QUnit.test("fsreplace1 no path", async assert => {
    const invalid = cockpit.channel({ payload: "fsreplace1", attrs: {} });
    await assert.rejects(invalid.wait(), /attribute 'path' required/, "rejects no path");
});

QUnit.test("fsreplace1 attrs", async assert => {
    await cockpit.init();
    assert.propContains(cockpit.info.channels, { fsreplace1: ['attrs'] });

    const valid = cockpit.channel({ payload: "fsreplace1", path: `${dir}/tmp`, attrs: {} });
    await valid.wait();
    valid.close(); // no-op

    const invalid = cockpit.channel({ payload: "fsreplace1", path: `${dir}/tmp`, attrs: { foo: 1 } });
    await assert.rejects(invalid.wait(), /"attrs" contains unsupported key\(s\): foo/, "rejects non-empty attrs");
});

QUnit.test("remove", async assert => {
    const exists = await cockpit.spawn(["bash", "-c", "test -f " + dir + "/bar && echo exists"]);
    assert.equal(exists, "exists\n", "exists");
    await cockpit.file(dir + "/bar").replace(null);
    const res = await cockpit.spawn(["bash", "-c", "test -f " + dir + "/bar || echo gone"]);
    assert.equal(res, "gone\n", "gone");
});

QUnit.test("abort replace", async assert => {
    const done = assert.async();
    assert.expect(0);

    const file = cockpit.file(dir + "/bar");

    let n = 0;
    function start_after_two() {
        n += 1;
        if (n == 2)
            done();
    }

    const first = file.replace("1234\n");
    const second = file.replace("abcd\n");

    try {
        await first;
        throw new Error("unexpectedly succeeded first promise");
    } catch (_error) {
        start_after_two();
    }

    await second;
    start_after_two();
});

QUnit.test("replace with tag", async assert => {
    const done = assert.async();
    const file = cockpit.file(dir + "/barfoo");

    file.read()
            // @ts-expect-error cannot represent read promise signature with tag
            .then(async (content, tag_1) => {
                assert.equal(content, null, "file does not exist");
                assert.equal(tag_1, "-", "first tag is -");
                const tag_2 = await file.replace("klmn\n", tag_1);
                assert.notEqual(tag_2, "-", "second tag is not -");
                const tag_3 = await file.replace("KLMN\n", tag_2);
                assert.notEqual(tag_3, tag_2, "third tag is different");
                try {
                    await file.replace("opqr\n", tag_2);
                    assert.ok(false, "should have failed");
                } catch (error) {
                    assert.equal((error as cockpit.BasicError).problem, "change-conflict", "wrong tag is rejected");
                    done();
                }
            });
});

QUnit.test("modify", async assert => {
    const file = cockpit.file(dir + "/quux");

    let n = 0;
    await file.modify(old => {
        n += 1;
        assert.equal(old, null, "no old content");
        return "ABCD\n";
    });
    assert.equal(n, 1, "callback called once");

    n = 0;
    await file.modify(old => {
        n += 1;
        assert.equal(old, "ABCD\n", "correct old content");
        return "dcba\n";
    });
    assert.equal(n, 1, "callback called once");

    assert.equal(await cockpit.spawn(["cat", dir + "/quux"]), "dcba\n", "correct content");

    // make sure that writing "" results in an empty file, not a deleted one
    n = 0;
    await file.modify(old => {
        n += 1;
        assert.equal(old, "dcba\n", "correct old content");
        return "";
    });
    assert.equal(n, 1, "callback called once");

    assert.equal(await cockpit.spawn(["cat", dir + "/quux"]), "", "correct content");

    // make sure that writing null deletes the file
    n = 0;
    await file.modify(old => {
        n += 1;
        assert.equal(old, "", "correct old content");
        return null;
    });
    assert.equal(n, 1, "callback called once");

    assert.rejects(cockpit.spawn(["cat", dir + "/quux"]), /No such file or directory/, "file deleted");
});

QUnit.test("modify with conflict", async assert => {
    const done = assert.async();
    assert.expect(6);

    const file = cockpit.file(dir + "/baz");

    let n = 0;
    file
            .modify(old => {
                n += 1;
                assert.equal(old, null, "no old content");
                return "ABCD\n";
            })
            .finally(() => assert.equal(n, 1, "callback called once"))

            // @ts-expect-error cannot represent modify promise signature with tag
            .then(async (content, tag) => {
                let n = 0;
                await cockpit.spawn(["bash", "-c", "sleep 1; echo XYZ > " + dir + "/baz"]);
                await file.modify(old => {
                    cockpit.assert(old !== null, "old is null");
                    n += 1;
                    if (n == 1)
                        assert.equal(old, "ABCD\n", "correct old (out-of-date) content");
                    else
                        assert.equal(old, "XYZ\n", "correct old (current) content");
                    return old.toLowerCase();
                }, content, tag);

                assert.equal(n, 2, "callback called twice");
                const res = await cockpit.spawn(["cat", dir + "/baz"]);
                assert.equal(res, "xyz\n", "correct content");
                done();
            });
});

QUnit.test("watching", assert => {
    const done = assert.async();
    assert.expect(7);

    const file = cockpit.file(dir + "/foobar");
    let n = 0;
    const watch = file.watch((content, tag) => {
        n += 1;
        if (n == 1) {
            assert.equal(content, null, "initially non-existent");
            assert.equal(tag, "-", "empty tag");
            cockpit.spawn(["bash", "-c", "cd " + dir + " && echo 1234 > foobar.tmp && mv foobar.tmp foobar"]);
        } else if (n == 2) {
            assert.equal(content, "1234\n", "correct new content");
            assert.notEqual(tag, "-");
            cockpit.assert(tag !== null, "tag is null");
            assert.ok(tag.length > 5, "tag has a reasonable size");
            cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]);
        } else if (n == 3) {
            assert.equal(content, null, "finally non-existent");
            assert.equal(tag, "-", "empty tag");
            watch.remove();
            done();
        }
    });
});

QUnit.test("binary watching", assert => {
    const done = assert.async();
    assert.expect(3);

    const file = cockpit.file(dir + "/foobar", { binary: true });
    let n = 0;
    const watch = file.watch(content => {
        n += 1;
        if (n == 1) {
            assert.equal(content, null, "initially non-existent");
            cockpit.spawn(["bash", "-c", "cd " + dir + " && echo '//8BAg==' | base64 -d > foobar.tmp && mv foobar.tmp foobar"]);
        } else if (n == 2) {
            assert.deepEqual(content, new Uint8Array([255, 255, 1, 2]), "correct new content");
            cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]);
        } else if (n == 3) {
            assert.equal(content, null, "finally non-existent");
            watch.remove();
            done();
        }
    });
});

QUnit.test("syntax watching", assert => {
    const done = assert.async();
    assert.expect(3);

    const file = cockpit.file(dir + "/foobar.json", { syntax: JSON });
    let n = 0;
    const watch = file.watch((content, _tag, err) => {
        n += 1;
        if (n == 1) {
            assert.equal(content, null, "initially non-existent");
            cockpit.spawn(["bash", "-c", "cd " + dir + " && echo '[ 1, 2, 3, 4 ]' > foobar.json.tmp && mv foobar.json.tmp foobar.json"]);
        } else if (n == 2) {
            assert.deepEqual(content, [1, 2, 3, 4], "correct new content");
            cockpit.spawn(["bash", "-c", "echo 'hi-there-this-is-not-json'  > " + dir + "/foobar.json"]);
        } else if (n == 3) {
            assert.ok(err instanceof SyntaxError, "got SyntaxError error");
            watch.remove();
            done();
        } else
            assert.ok(false, "not reached");
    });
});

QUnit.test("watching without reading", assert => {
    const done = assert.async();
    assert.expect(7);

    const file = cockpit.file(dir + "/foobar");
    let n = 0;
    const watch = file.watch((content, tag) => {
        n += 1;
        if (n == 1) {
            assert.equal(content, null, "initially non-existent");
            assert.equal(tag, "-", "empty tag");
            cockpit.spawn(["bash", "-c", "cd " + dir + " && echo 1234 > foobar.tmp && mv foobar.tmp foobar"]);
        } else if (n == 2) {
            assert.equal(content, null, "no content as reading is disabled");
            assert.notEqual(tag, "-");
            cockpit.assert(tag !== null, "tag is null");
            assert.ok(tag.length > 5, "tag has a reasonable size");
            cockpit.spawn(["bash", "-c", "rm " + dir + "/foobar"]);
        } else if (n == 3) {
            assert.equal(content, null, "finally non-existent");
            assert.equal(tag, "-", "empty tag");
            watch.remove();
            done();
        }
    }, { read: false });
});

QUnit.test("watching without reading pre-created", async assert => {
    const done = assert.async();
    assert.expect(3);

    // Pre-create fsinfo test file
    const file = cockpit.file(dir + "/fsinfo");
    await file.replace("1234");
    const watch = file.watch((content, tag) => {
        assert.equal(content, null, "non-existant because read is false");
        assert.notEqual(tag, null, "non empty tag");
        cockpit.assert(tag !== null, "tag is null");
        assert.equal(tag.startsWith("1:"), true, "tag always starts with 1:");
        watch.remove();
        done();
    }, { read: false });
});

QUnit.test("watching directory", assert => {
    const done = assert.async();
    assert.expect(20);

    let n = 0;
    const watch = cockpit.channel({ payload: "fswatch1", path: dir });
    watch.addEventListener("message", (_event, payload) => {
        const msg = JSON.parse(payload);
        n += 1;

        if (n == 1) {
            assert.equal(msg.event, "created", "world.txt created");
            assert.equal(msg.path, dir + "/world.txt");
            assert.equal(msg.type, "file");
            assert.notEqual(msg.tag, "-");
        } else if (n == 2) {
            assert.equal(msg.event, "changed", "world.txt changed");
            assert.equal(msg.path, dir + "/world.txt");
            assert.notEqual(msg.tag, "-");
        } else if (n == 3) {
            assert.equal(msg.event, "done-hint", "world.txt done-hint");
            assert.equal(msg.path, dir + "/world.txt");
            assert.notEqual(msg.tag, "-");

            cockpit.spawn(["chmod", "001", `${dir}/world.txt`]);
        } else if (n == 4) {
            assert.equal(msg.event, "attribute-changed", "world.txt attribute-changed");
            assert.equal(msg.path, dir + "/world.txt");
            assert.notEqual(msg.tag, "-");

            cockpit.spawn(["rm", `${dir}/world.txt`]);
        } else if (n == 5) {
            assert.equal(msg.event, "deleted", "world.txt deleted");
            assert.equal(msg.path, dir + "/world.txt");
            assert.equal(msg.tag, "-");

            cockpit.spawn(["mkdir", `${dir}/somedir`]);
        } else if (n == 6) {
            assert.equal(msg.event, "created", "somedir created");
            assert.equal(msg.path, dir + "/somedir");
            assert.equal(msg.type, "directory");
            assert.notEqual(msg.tag, "-");

            watch.close();
            done();
        }
    });

    // trigger the first event
    cockpit.spawn(["sh", "-c", `echo hello > ${dir}/world.txt`]);
});

QUnit.test("watching error", async assert => {
    const dir = await cockpit.spawn([
        'sh', '-c',
        `
           cd "$(mktemp -d)"
           echo -n "$(pwd)"

           mkdir dir
           echo dir file > dir/dir-file.txt
           echo do not read this > dir-file.xtx
           chmod 0 dir
        `
    ]);

    const file = cockpit.file(`${dir}/dir/file`);

    try {
        await new Promise<void>(resolve => {
            file.watch((content, tag, error) => {
                assert.equal(content, null);
                assert.equal(tag, null);
                cockpit.assert(error !== null, "error is null");
                assert.equal(error.problem, 'access-denied');
                resolve();
            });
        });
    } finally {
        file.close();
        await cockpit.spawn(["chmod", "-R", "u+rwX", dir]);
        await cockpit.spawn(["rm", "-rf", dir]);
    }
});

QUnit.test("closing", assert => {
    const done = assert.async();
    assert.expect(2);

    const file = cockpit.file(dir + "/foobarbaz");
    const watch = file.watch(changed);

    let n = 0;
    function start_after_two() {
        n += 1;
        if (n == 2) {
            watch.remove();
            done();
        }
    }

    file.read()
            .then(() => {
                assert.ok(false, "read didn't complete");
            })
            .catch(error => {
                assert.equal(error.problem, "cancelled", "read got cancelled");
                start_after_two();
            });

    function changed(_content: string | null, _tag: string | null, err: cockpit.BasicError | null) {
        if (err) {
            assert.equal(err.problem, "cancelled", "watch got cancelled");
            start_after_two();
        } else {
            assert.ok(false, "not reached");
        }
    }

    file.close();
});

QUnit.test("channel options", async assert => {
    const data = await cockpit.file(dir + "/foo", { binary: true }).read();
    assert.ok(data instanceof Uint8Array, "options applied, got binary data");
});

QUnit.test("remove testdir", async assert => {
    await cockpit.spawn(["rm", "-rf", dir]);
    assert.ok(true, "did not crash");
});

(async () => {
    const resp = await cockpit.spawn(["bash", "-c", "d=$(mktemp -d); echo '1234' >$d/foo; echo '{ \"foo\": 12 }' >$d/foo.json; echo -en '\\x00\\x01\\x02\\x03' >$d/foo.bin; dd if=/dev/zero of=$d/large.bin bs=1k count=512; echo $d"]);
    dir = resp.replace(/\n$/, "");
    QUnit.start();
})();
