/**
 * Guards for scripts/set-version.sh — the release version stamper.
 *
 * TRACES: DR-153 | UT-150
 *
 * These run the real script against a throwaway copy of the manifests, because
 * the failure modes are all in the shell, not in any TS logic: a regex that also
 * matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
 * a CI ref reaching the validator verbatim.
 *
 * The versionCode formula matters most. Android refuses an update whose code is
 * lower than the installed one, and builds already in the field shipped code
 * 1000 — so any formula that can emit a smaller number for a *newer* release
 * bricks updates for those users, silently and irreversibly.
 */

import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";

const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const script = path.join(repoRoot, "scripts", "set-version.sh");

let tmp: string;

/** A minimal repo skeleton: just the files the script rewrites. */
function seed(dir: string) {
  fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
  fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
  fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
  fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);

  fs.writeFileSync(
    path.join(dir, "package.json"),
    JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
  );
  fs.writeFileSync(
    path.join(dir, "src-tauri", "tauri.conf.json"),
    JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
  );
  // A dependency carrying its own `version =` is the trap: a greedy regex
  // rewrites it too and the build then resolves the wrong crate.
  fs.writeFileSync(
    path.join(dir, "src-tauri", "Cargo.toml"),
    ['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
  );
  fs.writeFileSync(
    path.join(dir, "src-tauri", "Cargo.lock"),
    ['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
  );
  fs.writeFileSync(
    path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
    "tauri.android.versionCode=1\n"
  );
}

function run(version: string, dir = tmp) {
  return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
    cwd: dir,
    encoding: "utf-8",
  });
}

function read(rel: string): string {
  return fs.readFileSync(path.join(tmp, rel), "utf-8");
}

function versionCode(): number {
  const m = read("src-tauri/gen/android/app/tauri.properties").match(
    /^tauri\.android\.versionCode=(\d+)$/m
  );
  return m ? Number(m[1]) : NaN;
}

beforeEach(() => {
  tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
  seed(tmp);
});

afterEach(() => {
  fs.rmSync(tmp, { recursive: true, force: true });
});

describe("set-version.sh", () => {
  it("stamps the version into all four manifests", () => {
    run("0.5.0");
    expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
    expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
    expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
    expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
  });

  it("accepts a leading v, as git tags are written", () => {
    run("v0.5.0");
    expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
  });

  // The regression that motivates anchoring the patterns.
  it("does not rewrite dependency versions", () => {
    run("0.5.0");
    expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
    expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
    expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
  });

  describe("Android versionCode", () => {
    // Codes below 1000 are already in the field; a newer release must never
    // produce a smaller number than an older one.
    it("clears the 1000 floor shipped by earlier builds", () => {
      run("0.0.1");
      expect(versionCode()).toBeGreaterThan(1000);
    });

    it("uses 1000 + major*10000 + minor*100 + patch", () => {
      const cases: Array<[string, number]> = [
        ["0.0.14", 1014],
        ["0.0.15", 1015],
        ["0.1.0", 1100],
        ["0.4.8", 1408],
        ["0.5.0", 1500],
        ["1.0.0", 11000],
      ];
      for (const [version, code] of cases) {
        seed(tmp);
        run(version);
        expect(versionCode(), `versionCode for ${version}`).toBe(code);
      }
    });

    it("increases monotonically across an upgrade sequence", () => {
      const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
      const codes = ordered.map((v) => {
        seed(tmp);
        run(v);
        return versionCode();
      });
      const sorted = [...codes].sort((a, b) => a - b);
      expect(codes).toEqual(sorted);
      expect(new Set(codes).size).toBe(codes.length);
    });

    // `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
    // stripped before the arithmetic.
    it("derives the code from the numeric core of a prerelease", () => {
      run("0.6.0-rc1");
      expect(versionCode()).toBe(1600);
      expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
    });
  });

  describe("input validation", () => {
    it("rejects a malformed version without writing anything", () => {
      expect(() => run("not-a-version")).toThrow();
      // The manifests must be untouched, not half-written.
      expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
      expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
    });

    // CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
    // that is still a full ref, and must not fail the job.
    it("falls back to a dev version when handed a non-tag ref", () => {
      const out = run("refs/heads/master");
      expect(out).not.toMatch(/refs\/heads/);
      expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
    });
  });
});
