// gtd-api — minimal GTD backend. Bun + Postgres (personal-db), bearer-token auth.
import { SQL } from "bun";

const sql = new SQL(process.env.DATABASE_URL!);
const API_TOKEN = process.env.API_TOKEN!;
if (!API_TOKEN) throw new Error("API_TOKEN not set");

const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
  due_date::text as due_date, defer_date::text as defer_date,
  priority, estimate_min, completed_at, sort_order, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;

const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
};

function json(data: unknown, status = 200) {
  return Response.json(data, { status, headers: CORS });
}
function err(message: string, status: number) {
  return json({ error: message }, status);
}

// Fields a client may set. completed_at is derived from status server-side.
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "sort_order"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];

function pick(body: Record<string, unknown>, fields: string[]) {
  const out: Record<string, unknown> = {};
  for (const f of fields) if (f in body) out[f] = body[f] === "" ? null : body[f];
  return out;
}

async function handle(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const path = url.pathname;
  const method = req.method;

  if (method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
  if (path === "/healthz") return json({ ok: true });

  if (req.headers.get("authorization") !== `Bearer ${API_TOKEN}`) return err("unauthorized", 401);

  const body = method === "POST" || method === "PATCH" ? await req.json().catch(() => null) : null;
  if ((method === "POST" || method === "PATCH") && !body) return err("invalid JSON body", 400);

  // ---- tasks ----
  if (path === "/v1/tasks" && method === "GET") {
    const status = url.searchParams.get("status");
    const projectId = url.searchParams.get("project_id");
    if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400);
    const where: string[] = [];
    const params: unknown[] = [];
    if (status) { params.push(status); where.push(`status = $${params.length}`); }
    else where.push(`status != 'trashed'`);
    if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); }
    const rows = await sql.unsafe(
      `select ${TASK_COLS} from tasks where ${where.join(" and ")}
       order by sort_order, created_at desc`, params);
    return json(rows);
  }

  if (path === "/v1/tasks" && method === "POST") {
    const t = pick(body, TASK_FIELDS);
    if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
    if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
    const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
    // Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
    const rows = await sql.unsafe(
      `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, sort_order)
       values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
       on conflict (id) do nothing
       returning ${TASK_COLS}`,
      [id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
       t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4,
       t.estimate_min ?? null, t.sort_order ?? 0]);
    if (rows.length) return json(rows[0], 201);
    const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
    return json(existing[0], 200);
  }

  let m = path.match(/^\/v1\/tasks\/([0-9a-f-]{36})$/);
  if (m && method === "GET") {
    const rows = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [m[1]]);
    return rows.length ? json(rows[0]) : err("not found", 404);
  }
  if (m && (method === "PATCH" || method === "DELETE")) {
    const id = m[1];
    const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS);
    if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400);
    const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
    if (!existing.length) return err("not found", 404);
    const cur = { ...existing[0], ...patch };
    const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
    const rows = await sql.unsafe(
      `update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
         due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, completed_at=$12, sort_order=$13,
         updated_at=now()
       where id=$1 returning ${TASK_COLS}`,
      [id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
       cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, completedAt, cur.sort_order]);
    return json(rows[0]);
  }

  // ---- projects ----
  if (path === "/v1/projects" && method === "GET") {
    const rows = await sql.unsafe(
      `select ${PROJECT_COLS},
         (select count(*) from tasks t where t.project_id = projects.id
            and t.status not in ('done', 'trashed'))::int as open_tasks
       from projects where status != 'dropped'
       order by sort_order, created_at desc`, []);
    return json(rows);
  }

  if (path === "/v1/projects" && method === "POST") {
    const p = pick(body, PROJECT_FIELDS);
    if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400);
    if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400);
    const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
    const rows = await sql.unsafe(
      `insert into projects (id, name, status, notes, sort_order)
       values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5)
       on conflict (id) do nothing
       returning ${PROJECT_COLS}`,
      [id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]);
    if (rows.length) return json(rows[0], 201);
    const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
    return json(existing[0], 200);
  }

  m = path.match(/^\/v1\/projects\/([0-9a-f-]{36})$/);
  if (m && (method === "PATCH" || method === "DELETE")) {
    const id = m[1];
    const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS);
    if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400);
    const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
    if (!existing.length) return err("not found", 404);
    const cur = { ...existing[0], ...patch };
    const rows = await sql.unsafe(
      `update projects set name=$2, status=$3, notes=$4, sort_order=$5, updated_at=now()
       where id=$1 returning ${PROJECT_COLS}`,
      [id, cur.name, cur.status, cur.notes, cur.sort_order]);
    return json(rows[0]);
  }

  if (path === "/v1/contexts" && method === "GET") {
    const rows = await sql.unsafe(
      `select distinct context from tasks
       where context is not null and status not in ('done', 'trashed') order by context`, []);
    return json(rows.map((r: { context: string }) => r.context));
  }

  return err("not found", 404);
}

Bun.serve({
  port: 3000,
  hostname: "0.0.0.0",
  fetch: (req) =>
    handle(req).catch((e) => {
      console.error(e);
      return err("internal error", 500);
    }),
});

console.log("gtd-api listening on :3000");
