/**
 * Agent-state smart-HTTP route group.
 *
 * One URL grammar exposed under a Hono sub-app:
 *
 *   /api/tenants/:tenantId/workflows/runs/:runId/state.git/...
 *     -> RepoId { kind: "agent-state", id: runId }
 *     (a folded run's runtime state, written by the sidecar's first
 *      state pack to `agents/<runId>`)
 *
 * The grammar is READ-ONLY over HTTP. Upload-pack
 * (`info/refs?service=git-upload-pack` and `POST /git-upload-pack`)
 * runs behind the same bearer middleware the asset routes use, with
 * a pre-resolved authz verdict on the constructed UserPrincipal.
 * Receive-pack
 * (`info/refs?service=git-receive-pack` and `POST /git-receive-pack`)
 * is denied at the edge with pkt-line-framed responses so a
 * `git push -v` parses the protocol-level rejection even when no
 * Authorization header is present. The receive-pack denial
 * middleware is mounted BEFORE bearer middleware in the app layer;
 * the substrate's `handleReceivePack` is NOT imported here — agent
 * state never accepts writes over HTTP.
 *
 * The resolver verifies the folded run belongs to `:tenantId` and
 * 404s otherwise. The per-run repo is lazily-materialised: on a
 * never-pushed run, `listRefs` returns the empty list and the
 * advertise layer emits the `capabilities^{}` empty-repo record so a
 * stock `git clone` succeeds against an empty tree rather than 404ing.
 */

import { and, eq, isNull, isNotNull } from "drizzle-orm";
import { Hono, type Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { MiddlewareHandler } from "hono";

import { authorize } from "@intx/authz";
import { workflowRun } from "@intx/db/schema";
import type { DB } from "@intx/db";
import { repoActionToGrantVerb } from "@intx/hub-common";
import { getLogger } from "@intx/log";
import type {
  RefEntry,
  RepoId,
  RepoStore,
  UserPrincipal,
} from "@intx/hub-sessions";
import type { RepoAction } from "@intx/types/sidecar";
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";

import type {
  GitTokenClaims,
  TenantGitTokenEnv,
} from "../middleware/git-token-auth";
import {
  advertiseUploadPack,
  type RefSource,
} from "../git-http/advertise-refs";
import {
  handleUploadPack,
  type UploadPackRepoStore,
} from "../git-http/upload-pack";
import { writePktLine, writeFlush } from "../git-http/pkt-line";

const log = getLogger(["hub", "agent-state-git"]);

// ----- Receive-pack denial: pkt-line responses -----------------------
//
// The advertise denial body is locked to:
//
//   # service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n
//
// The leading `# service=` line and the `0000` flush packet mirror the
// shape stock git emits for a successful advertise; the trailing
// `ERR ...` substring is what `git push -v` surfaces as the visible
// rejection reason. The body is emitted verbatim (not pkt-line
// framed beyond the literal `0000` flush in the middle).
const RECEIVE_PACK_ADVERTISE_DENY_BODY =
  "# service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n";

// The POST denial reason that surfaces in the `unpack` and per-ref
// `ng` pkt-lines. Short, stable, support-recognisable.
const RECEIVE_PACK_POST_DENY_REASON = "agent-state-readonly";

const RECEIVE_PACK_RESULT_CONTENT_TYPE =
  "application/x-git-receive-pack-result";
const RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE =
  "application/x-git-receive-pack-advertisement";

function parseHex4(buf: Uint8Array, off: number): number {
  let v = 0;
  for (let i = 0; i < 4; i++) {
    const c = buf[off + i];
    if (c === undefined) {
      throw new Error("truncated pkt-line: short header");
    }
    let d: number;
    if (c >= 0x30 && c <= 0x39) {
      d = c - 0x30;
    } else if (c >= 0x61 && c <= 0x66) {
      d = c - 0x61 + 10;
    } else if (c >= 0x41 && c <= 0x46) {
      d = c - 0x41 + 10;
    } else {
      throw new Error("malformed pkt-line length");
    }
    v = (v << 4) | d;
  }
  return v;
}

/**
 * Minimal parse of the receive-pack request body to extract the list
 * of ref names. The full request grammar includes old/new shas and
 * optional capability tail; we only care about the ref name for the
 * `ng <ref> <reason>` lines. Capabilities and packfile bytes that
 * follow the flush packet are ignored.
 */
function extractReceivePackRefs(body: Uint8Array): string[] {
  const decoder = new TextDecoder();
  const refs: string[] = [];
  let off = 0;
  while (off + 4 <= body.length) {
    const length = parseHex4(body, off);
    off += 4;
    if (length === 0) {
      // flush: end of commands
      break;
    }
    if (length < 4) {
      throw new Error(`reserved pkt-line length: ${length}`);
    }
    const bodyLen = length - 4;
    if (off + bodyLen > body.length) {
      throw new Error("truncated receive-pack pkt-line body");
    }
    const line = decoder.decode(body.subarray(off, off + bodyLen));
    off += bodyLen;
    // strip optional capability tail after \0 and trailing \n
    const nulIdx = line.indexOf("\0");
    const head = nulIdx === -1 ? line : line.substring(0, nulIdx);
    const trimmed = head.endsWith("\n") ? head.slice(0, -1) : head;
    const parts = trimmed.split(" ");
    if (parts.length < 3) continue;
    const ref = parts.slice(2).join(" ");
    if (ref.length > 0) refs.push(ref);
  }
  return refs;
}

async function writeReceivePackDenyReport(
  writer: WritableStreamDefaultWriter<Uint8Array>,
  refs: readonly string[],
): Promise<void> {
  await writePktLine(writer, `unpack ${RECEIVE_PACK_POST_DENY_REASON}\n`);
  for (const ref of refs) {
    await writePktLine(writer, `ng ${ref} ${RECEIVE_PACK_POST_DENY_REASON}\n`);
  }
  await writeFlush(writer);
}

function buildReceivePackPostDenyStream(
  refs: readonly string[],
): ReadableStream<Uint8Array> {
  return new ReadableStream<Uint8Array>({
    async start(controller) {
      const sink = new WritableStream<Uint8Array>({
        write(chunk) {
          controller.enqueue(chunk);
        },
      });
      const writer = sink.getWriter();
      try {
        await writeReceivePackDenyReport(writer, refs);
        await writer.close();
        controller.close();
      } catch (cause) {
        await writer.abort(cause).catch(() => undefined);
        controller.error(cause);
      }
    },
  });
}

// ----- Receive-pack deny middleware ---------------------------------
//
// Mounted ahead of the bearer middleware. Intercepts:
//   - GET .../info/refs?service=git-receive-pack -> 403 with locked body
//   - POST .../git-receive-pack                  -> 200 pkt-line denial
// Other requests (upload-pack info/refs and POST) call next() so the
// subsequent bearer + upload-pack handlers run normally.

function urlPathEndsWith(path: string, suffix: string): boolean {
  return path === suffix || path.endsWith(suffix);
}

/**
 * Hono middleware that denies any receive-pack request reaching the
 * agent-state route surface, regardless of bearer-token presence.
 * Mount BEFORE `createGitTokenAuth` so unauthenticated `git push -v`
 * sees the locked pkt-line ERR rather than a 401.
 */
export function createAgentStateReceivePackDeny(): MiddlewareHandler {
  return createMiddleware(async (c, next) => {
    const method = c.req.method.toUpperCase();
    const path = c.req.path;

    if (method === "GET" && urlPathEndsWith(path, "/info/refs")) {
      const service = c.req.query("service");
      if (service === "git-receive-pack") {
        log.info("receive-pack advertise denied {path}", { path });
        return new Response(RECEIVE_PACK_ADVERTISE_DENY_BODY, {
          status: 403,
          headers: {
            "content-type": RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE,
            "cache-control": "no-cache",
          },
        });
      }
      // upload-pack advertise: fall through.
      await next();
      return;
    }

    if (method === "POST" && urlPathEndsWith(path, "/git-receive-pack")) {
      log.info("receive-pack POST denied {path}", { path });
      let refs: string[] = [];
      try {
        const body = new Uint8Array(await c.req.raw.arrayBuffer());
        refs = extractReceivePackRefs(body);
      } catch (err) {
        // Malformed body still gets a deny report with no per-ref lines.
        log.info("receive-pack POST: body parse failed {err}", {
          err: err instanceof Error ? err.message : String(err),
        });
        refs = [];
      }
      const stream = buildReceivePackPostDenyStream(refs);
      return new Response(stream, {
        status: 200,
        headers: {
          "content-type": RECEIVE_PACK_RESULT_CONTENT_TYPE,
          "cache-control": "no-cache",
        },
      });
    }

    await next();
  });
}

// ----- Pre-resolved authz + UserPrincipal construction --------------

function dateToNumber(d: Date): number {
  return d.getTime();
}

async function resolveAuthzVerdict(args: {
  grantStore: GrantStore;
  conditionRegistry: ConditionRegistry;
  principalId: string;
  tenantId: string;
  agentStateId: string;
  action: RepoAction;
}): Promise<UserPrincipal["authz"]> {
  const resource = `agent-state:${args.agentStateId}`;
  const grantVerb = repoActionToGrantVerb(args.action);
  const verdict = await authorize(
    args.grantStore,
    args.principalId,
    args.tenantId,
    resource,
    grantVerb,
    args.conditionRegistry,
  );
  return {
    effect: verdict.effect === "allow" ? "allow" : "deny",
    resource,
    grantVerb,
  };
}

function buildUserPrincipal(args: {
  principalId: string;
  tenantId: string;
  authz: UserPrincipal["authz"];
  claims: GitTokenClaims;
}): UserPrincipal {
  return {
    kind: "user",
    principalId: args.principalId,
    tenantId: args.tenantId,
    authz: args.authz,
    tokenClaims: {
      refPattern: args.claims.refPattern,
      actions: args.claims.actions,
      expiresAt: dateToNumber(args.claims.expiresAt),
    },
  };
}

// ----- Substrate adapters -------------------------------------------

function makeRefSource(
  repoStore: RepoStore,
  principal: UserPrincipal,
): RefSource {
  return {
    async listRefs(_p, repoId): Promise<RefEntry[]> {
      return repoStore.listRefs(principal, repoId);
    },
    async resolveHead(_p, repoId) {
      return repoStore.resolveHead(principal, repoId);
    },
  };
}

function makeUploadPackStore(
  repoStore: RepoStore,
  principal: UserPrincipal,
): UploadPackRepoStore {
  return {
    async listRefs(_p, repoId): Promise<RefEntry[]> {
      return repoStore.listRefs(principal, repoId);
    },
    async getRepoDir(_p, repoId): Promise<string> {
      return repoStore.getRepoDir(repoId);
    },
  };
}

// ----- Resolver shape -----------------------------------------------

type SmartHttpResolved = {
  principal: UserPrincipal;
  repoId: RepoId;
};

type ResolveResult =
  | { ok: true; resolved: SmartHttpResolved }
  | {
      ok: false;
      status: 400 | 403 | 404;
      code: string;
      message: string;
    };

async function resolveAgentStateId(
  db: DB["db"],
  tenantId: string,
  paramId: string,
): Promise<{ ok: true; id: string } | { ok: false; reason: string }> {
  // Only a folded launch run owns state at `agents/<runId>`: it is born with a
  // routing address and no deployment (`deploymentId IS NULL AND address IS NOT
  // NULL`), so its sidecar persists via the plain-address path. A deployment
  // anchor/child run keeps its state under `workflow-runs/<slug>` instead, so
  // the shape gate keeps those out of this route. Deliberately NOT gated on
  // status: a stopped run's final state is exactly what a read-only clone
  // serves, matching the legacy instance route -- do not add a `running` guard.
  const row = await db.query.workflowRun.findFirst({
    where: and(
      eq(workflowRun.id, paramId),
      eq(workflowRun.tenantId, tenantId),
      isNull(workflowRun.deploymentId),
      isNotNull(workflowRun.address),
    ),
  });
  if (row === undefined) {
    return { ok: false, reason: `no run ${paramId} in tenant` };
  }
  return { ok: true, id: row.id };
}

type ResolveSmartHttpDeps = {
  db: DB["db"];
  grantStore: GrantStore;
  conditionRegistry: ConditionRegistry;
};

async function resolveSmartHttp(
  deps: ResolveSmartHttpDeps,
  c: Context<TenantGitTokenEnv>,
  action: RepoAction,
): Promise<ResolveResult> {
  const tenantRow = c.get("tenant");
  const principalRow = c.get("principal");
  const claims: GitTokenClaims = c.get("git-token-claims");
  // The typed env makes this unreachable today, but if the route
  // module is ever mounted without the bearer middleware ahead of
  // it, surface a misconfiguration rather than a downstream
  // TypeError. A 401 would imply the client was unauthenticated;
  // a missing claims object means the server is misconfigured.
  if (claims === undefined) {
    throw new Error(
      "smart-HTTP route handler invoked without bearer middleware; check the mount order in app.ts",
    );
  }
  if (!claims.actions.includes(action)) {
    return {
      ok: false,
      status: 403,
      code: "forbidden",
      message: `token claims do not include action ${action}`,
    };
  }
  const paramId = c.req.param("runId");
  if (paramId === undefined) {
    return {
      ok: false,
      status: 400,
      code: "bad_request",
      message: "missing :runId in URL",
    };
  }
  const tenantId = tenantRow.id;
  const resolved = await resolveAgentStateId(deps.db, tenantId, paramId);
  if (!resolved.ok) {
    return {
      ok: false,
      status: 404,
      code: "not_found",
      message: resolved.reason,
    };
  }
  const authz = await resolveAuthzVerdict({
    grantStore: deps.grantStore,
    conditionRegistry: deps.conditionRegistry,
    principalId: principalRow.id,
    tenantId,
    agentStateId: resolved.id,
    action,
  });
  if (authz.effect !== "allow") {
    log.info(
      "agent-state authz denied {tenantId} run={id} principal={principalId}",
      {
        tenantId,
        id: resolved.id,
        principalId: principalRow.id,
      },
    );
    return {
      ok: false,
      status: 403,
      code: "forbidden",
      message: "authz denied",
    };
  }
  const principal = buildUserPrincipal({
    principalId: principalRow.id,
    tenantId,
    authz,
    claims,
  });
  const repoId: RepoId = { kind: "agent-state", id: resolved.id };
  return { ok: true, resolved: { principal, repoId } };
}

// ----- Upload-pack route factory ------------------------------------

export type CreateAgentStateGitRoutesDeps = {
  db: DB["db"];
  repoStore: RepoStore;
  grantStore: GrantStore;
  conditionRegistry: ConditionRegistry;
};

export function createAgentStateRunGitRoutes(
  deps: CreateAgentStateGitRoutesDeps,
): Hono<TenantGitTokenEnv> {
  const app = new Hono<TenantGitTokenEnv>();

  app.get("/:runId/state.git/info/refs", async (c) => {
    const service = c.req.query("service");
    if (service !== "git-upload-pack") {
      // The receive-pack case is handled by the deny middleware above;
      // anything else is a bad request.
      return c.json(
        {
          error: {
            code: "bad_request",
            message: "info/refs requires service=git-upload-pack",
          },
        },
        400,
      );
    }
    const r = await resolveSmartHttp(deps, c, "resolveRef");
    if (!r.ok) {
      return c.json({ error: { code: r.code, message: r.message } }, r.status);
    }
    const refSource = makeRefSource(deps.repoStore, r.resolved.principal);
    const stream = await advertiseUploadPack(
      refSource,
      r.resolved.principal,
      r.resolved.repoId,
    );
    return new Response(stream, {
      status: 200,
      headers: {
        "content-type": "application/x-git-upload-pack-advertisement",
        "cache-control": "no-cache",
      },
    });
  });

  app.post("/:runId/state.git/git-upload-pack", async (c) => {
    const r = await resolveSmartHttp(deps, c, "createPack");
    if (!r.ok) {
      return c.json({ error: { code: r.code, message: r.message } }, r.status);
    }
    return handleUploadPack(
      makeUploadPackStore(deps.repoStore, r.resolved.principal),
      r.resolved.principal,
      r.resolved.repoId,
      c.req.raw,
    );
  });

  return app;
}

/**
 * Smart-HTTP paths excluded from the OpenAPI document. The agent-state
 * routes serve binary git wire vocabulary; advertising them in the
 * generated spec would invite client codegen to treat them as JSON
 * endpoints.
 */
export const AGENT_STATE_OPENAPI_EXCLUDE_GLOBS = [
  "/api/tenants/*/workflows/runs/*/state.git/info/refs",
  "/api/tenants/*/workflows/runs/*/state.git/git-upload-pack",
  "/api/tenants/*/workflows/runs/*/state.git/git-receive-pack",
] as const;
