import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import {
  getServiceConnection,
  saveServiceConnection,
  deleteServiceConnection,
  recordServiceProbe,
  getServiceInstanceUrl,
  getOwnerUser,
} from 'officerdb';
import {
  callGitea,
  getGiteaConfig,
  invalidateGiteaConfig,
  invalidateAllGiteaConfigs,
  normalizeBase,
  probe,
} from './upstream';
import { API_URL } from '../../officer-url.mjs';

// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
//
//   GET      /_health        is the instance up, and does the stored token work
//   GET      /_config        the connection MINUS the token
//   PUT/POST /_config        { url, token } — validated live before it is stored
//   DELETE   /_config        forget the connection
//   ANY      /_api/*         forwarded to the instance's own /api/*, token attached
//
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
// Gitea genuinely is one-of: nobody runs two.
//
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

// Everything under /api/v1 the UI legitimately needs.
//
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
// into a connection form should not silently become a remote root shell for the instance. If an admin
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
const ALLOWED = [
  /^\/api\/v1\/version(\/|$|\?)/,
  /^\/api\/v1\/user(\/|$|\?)/,
  // Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES
  // access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there
  // precisely to stop a token escalating its own scopes, and answers `401 auth required` to the
  // `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is
  // GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check.
  /^\/api\/v1\/users(\/|$|\?)/,
  /^\/api\/v1\/orgs(\/|$|\?)/,
  /^\/api\/v1\/org(\/|$|\?)/,
  /^\/api\/v1\/teams(\/|$|\?)/,
  /^\/api\/v1\/repos(\/|$|\?)/,
  /^\/api\/v1\/repositories(\/|$|\?)/,
  /^\/api\/v1\/notifications(\/|$|\?)/,
  /^\/api\/v1\/issues(\/|$|\?)/,
  /^\/api\/v1\/packages(\/|$|\?)/,
  // Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
  // default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
  /^\/api\/v1\/settings(\/|$|\?)/,
  /^\/api\/v1\/topics(\/|$|\?)/,
  /^\/api\/v1\/licenses(\/|$|\?)/,
  /^\/api\/v1\/gitignore(\/|$|\?)/,
  /^\/api\/v1\/label(\/|$|\?)/,
  // Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
  // instance resolves relative links, #issue references and @mentions against the repo context, which a
  // client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
  /^\/api\/v1\/markdown(\/|$|\?)/,
  /^\/api\/v1\/markup(\/|$|\?)/,
];

function getFreePort(): number {
  const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
  const p = probeServer.port;
  probeServer.stop(true);
  if (p == null) throw new Error('failed to acquire a free port');
  return p;
}

const port = getFreePort();
const bad = (error: string, status = 400) => Response.json({ error }, { status });

/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
async function isOwner(userId: number): Promise<boolean> {
  const owner = await getOwnerUser();
  return !!owner && owner.id === userId;
}

// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
// not connected yet" from "there is nothing here to connect to", which are different screens.
//
// `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the
// invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could
// not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered.
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
// with its public host, so links have to be rebased client-side — see retargetUrls.
async function connectionState(userId: number): Promise<Response> {
  const connection = await getServiceConnection(userId, 'gitea');
  const instanceUrl = await getServiceInstanceUrl('gitea');
  return Response.json({
    configured: !!connection,
    connection,
    instanceUrl,
    instanceConfigured: !!instanceUrl,
    isOwner: await isOwner(userId),
  });
}

async function handleConfig(req: Request, userId: number): Promise<Response> {
  if (req.method === 'GET') return connectionState(userId);

  if (req.method === 'DELETE') {
    const owner = await isOwner(userId);
    await deleteServiceConnection(userId, 'gitea');
    // The owner disconnecting removes the instance every member's row inherits, so every cached config
    // is now wrong — they must all resolve to null rather than keep talking to a server the platform no
    // longer claims. A member disconnecting only affects themselves.
    if (owner) invalidateAllGiteaConfigs();
    else invalidateGiteaConfig(userId);
    return connectionState(userId);
  }

  if (req.method === 'PUT' || req.method === 'POST') {
    const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
    // A blank token on a save means "keep the stored one", so the URL can be corrected without the
    // token being re-pasted. There is no way to read it back out to re-send it.
    const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
    const owner = await isOwner(userId);

    // The instance is the owner's; the token is each person's own. Which half of the form you get is
    // decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
    // rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
    let base: string;
    if (owner) {
      const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
      if (!url) return bad('url is required');
      if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
      base = url;
    } else {
      // Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
      // and leave them debugging a screen that is quietly talking to a different server.
      if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
      const instance = await getServiceInstanceUrl('gitea');
      if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
      base = instance;
    }

    const existing = await getServiceConnection(userId, 'gitea');
    if (!token && !existing?.hasSecret) return bad('an access token is required the first time');

    // Probe with the credentials as they WILL be, so a save either stores something that works or
    // fails with the reason. Storing first and discovering later makes every downstream screen fail
    // mysteriously instead. For a member this also proves their token is valid against the shared
    // instance, which is the only thing that can be wrong on their side.
    const current = token ? null : await getGiteaConfig(userId);
    const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
    if (!result.ok) return bad(result.error ?? 'could not reach the instance');

    const connection = await saveServiceConnection({
      userId,
      service: 'gitea',
      // Null for anyone but the owner: their row is a credential, and inherits the instance on read.
      url: owner ? base : null,
      secret: token ?? undefined,
      version: result.version,
    });
    // An owner's save may have moved the instance, and every member's cached base was resolved from it.
    // Clearing only the caller would leave them pointed at the previous host until this process restarts.
    if (owner) invalidateAllGiteaConfigs();
    else invalidateGiteaConfig(userId);
    return Response.json({ connection, user: result.user, version: result.version });
  }

  return bad('method not allowed', 405);
}

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  // Release attachments and file uploads through the contents API.
  maxRequestBodySize: 64 * 1024 * 1024,
  async fetch(req) {
    const url = new URL(req.url);

    const officerUser = req.headers.get('X-Officer-User');
    const userId = Number(officerUser);
    if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
      return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
    }

    try {
      if (url.pathname === '/_config') return await handleConfig(req, userId);

      const config = await getGiteaConfig(userId);

      if (url.pathname === '/_health') {
        if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
        const started = Date.now();
        const result = await probe(config);
        const ms = Date.now() - started;
        if (!result.ok) {
          return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
        }
        await recordServiceProbe(userId, 'gitea', result.version);
        return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
      }

      if (url.pathname.startsWith('/_api/')) {
        if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
        const subpath = url.pathname.slice('/_api'.length) + url.search;
        if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);

        const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
        const upstream = await callGitea(config, subpath, {
          method: req.method,
          headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
          body: hasBody ? await req.arrayBuffer() : undefined,
        });
        return new Response(upstream.body, {
          status: upstream.status,
          headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
        });
      }
    } catch (err) {
      // Path only. A repository body is the owner's private code.
      console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
      return Response.json({ error: 'internal error' }, { status: 500 });
    }

    return Response.json({ error: 'not found' }, { status: 404 });
  },
});

console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);

type ReplyFn = (msg: SidecarEvent) => void;

function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
  switch (cmd.type) {
    case 'ping':
      reply({ type: 'pong', id: cmd.id });
      break;
    default:
      reply({
        type: 'error',
        id: (cmd as SidecarCommand).id,
        error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
      });
  }
}

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'gitea',
  capabilities: ['gitea'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    connection.send({ type: 'gitea:server', port });
    console.log(`[gitea] reported server port ${port} to API`);
  },
});

function shutdown(signal: string) {
  console.log(`[gitea] ${signal} received, shutting down...`);
  try {
    server.stop(true);
  } catch {
    /* already stopped */
  }
  connection.destroy();
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
