import {
  readFileSync,
  appendFileSync,
  mkdirSync,
  existsSync,
  readdirSync,
  openSync,
  closeSync,
  writeFileSync,
  rmSync,
  statSync,
} from "node:fs";
import { join } from "node:path";

const MANIFEST_FILE = "manifest.jsonl";
const INDEX_FILE = "index.md";

const TYPE_DIRS = {
  bgm: "audio/bgm",
  sfx: "audio/sfx",
  voice: "audio/voice",
  image: "images",
  icon: "images",
  logo: "images",
  brand: "images",
  video: "video",
  grade: "luts",
  lut: "luts",
  recipe: "recipes",
};

export function mediaDir(projectDir) {
  return join(projectDir, ".media");
}

export function manifestPath(projectDir) {
  return join(mediaDir(projectDir), MANIFEST_FILE);
}

export function indexPath(projectDir) {
  return join(mediaDir(projectDir), INDEX_FILE);
}

export function typeSubdir(type) {
  const sub = TYPE_DIRS[type];
  if (!sub) throw new Error(`unknown media type: ${type}`);
  return sub;
}

export function typeDirPath(projectDir, type) {
  return join(mediaDir(projectDir), typeSubdir(type));
}

export function readManifest(projectDir) {
  const p = manifestPath(projectDir);
  if (!existsSync(p)) return [];
  const raw = readFileSync(p, "utf8");
  const records = [];
  for (const line of raw.split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed) continue;
    try {
      records.push(JSON.parse(trimmed));
    } catch {
      // ponytail: skip malformed lines, don't crash
    }
  }
  return records;
}

export function appendRecord(projectDir, record) {
  const dir = mediaDir(projectDir);
  mkdirSync(dir, { recursive: true });
  const typeDir = typeDirPath(projectDir, record.type);
  mkdirSync(typeDir, { recursive: true });

  const p = manifestPath(projectDir);
  const line = JSON.stringify(record) + "\n";
  appendFileSync(p, line);
}

// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
// keying cache lookups on exact equality meant "Calm piano" and "calm  piano"
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
// whitespace) on both sides; the raw prompt is still stored for audit.
export function normalizePrompt(prompt) {
  return String(prompt ?? "")
    .trim()
    .toLowerCase()
    .replace(/\s+/g, " ");
}

export function findByPrompt(projectDir, prompt, type) {
  const key = normalizePrompt(prompt);
  if (!key) return null;
  const records = readManifest(projectDir);
  return (
    records.find(
      (r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
    ) || null
  );
}

export function findByEntity(projectDir, entity) {
  const lower = entity.toLowerCase();
  const records = readManifest(projectDir);
  return records.find((r) => r.entity && r.entity.toLowerCase() === lower) || null;
}

export function nextId(projectDir, type) {
  const records = readManifest(projectDir);
  const prefix = type;
  let max = 0;
  for (const r of records) {
    if (r.type !== type) continue;
    const m = r.id?.match(new RegExp(`^${prefix}_(\\d+)$`));
    if (m) max = Math.max(max, parseInt(m[1], 10));
  }
  return `${prefix}_${String(max + 1).padStart(3, "0")}`;
}

// Sync sleep (no busy-spin) for the allocation lock retry.
function sleepMs(ms) {
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

// Coarse per-project lock so concurrent resolves don't race on id allocation.
// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
// the project); fine for agent-scale concurrency — revisit if throughput needs
// finer locking. Date.now() is available here (a normal Node CLI, not a
// workflow DSL), so mtime-based staleness is safe.
const LOCK_STALE_MS = 15000;
const LOCK_TIMEOUT_MS = 20000;

function withLock(dir, fn) {
  const lock = join(dir, ".lock");
  const start = Date.now();
  for (;;) {
    try {
      closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
      break;
    } catch (err) {
      if (err.code !== "EEXIST") throw err;
      try {
        if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
          rmSync(lock, { force: true }); // steal a stale lock from a dead holder
          continue;
        }
      } catch {
        continue; // lock vanished between check and stat — retry the acquire
      }
      if (Date.now() - start > LOCK_TIMEOUT_MS) {
        throw new Error("media-use: timed out acquiring .media/.lock");
      }
      sleepMs(25);
    }
  }
  try {
    return fn();
  } finally {
    rmSync(lock, { force: true });
  }
}

// Atomically allocate the next free id for `type` AND reserve its file, so a
// slow download/copy between allocation and appendRecord can't let a concurrent
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
// across BOTH the manifest and any already-reserved files in the type dir, then
// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites
// it. Returns { id, localPath }.
export function allocateId(projectDir, type, ext) {
  mkdirSync(mediaDir(projectDir), { recursive: true });
  const typeDir = typeDirPath(projectDir, type);
  mkdirSync(typeDir, { recursive: true });
  return withLock(mediaDir(projectDir), () => {
    const re = new RegExp(`^${type}_(\\d+)`);
    let max = 0;
    for (const r of readManifest(projectDir)) {
      if (r.type !== type) continue;
      const m = r.id?.match(re);
      if (m) max = Math.max(max, parseInt(m[1], 10));
    }
    for (const f of readdirSync(typeDir)) {
      const m = f.match(re);
      if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended
    }
    const id = `${type}_${String(max + 1).padStart(3, "0")}`;
    const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
    writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation
    return { id, localPath };
  });
}

function reservedFile(projectDir, type, ext) {
  const allocation = allocateId(projectDir, type, ext);
  return { ...allocation, fullPath: join(projectDir, allocation.localPath) };
}

function rollbackReservation(reservation) {
  rmSync(reservation.fullPath, { force: true });
}

// A reservation is committed only when populate returns a non-null value.
// Throwing/rejecting or returning null means no usable asset was produced, so
// the placeholder must be released. Keeping this transaction beside allocateId
// prevents individual provider/cache/LUT paths from forgetting the rollback.
export function withReservedFileSync(projectDir, type, ext, populate) {
  const reservation = reservedFile(projectDir, type, ext);
  try {
    const result = populate(reservation);
    if (result == null) rollbackReservation(reservation);
    return result;
  } catch (error) {
    rollbackReservation(reservation);
    throw error;
  }
}

export async function withReservedFile(projectDir, type, ext, populate) {
  const reservation = reservedFile(projectDir, type, ext);
  try {
    const result = await populate(reservation);
    if (result == null) rollbackReservation(reservation);
    return result;
  } catch (error) {
    rollbackReservation(reservation);
    throw error;
  }
}
