import { homedir, hostname } from 'node:os';
import { join } from 'node:path';
import { PIGGY_MODES } from '@pig/core';
import { z } from 'zod';
import { isPiggyModelId, piggyDefaultModelId } from './agent/models';

/**
 * Where the Prime Agent harness is allowed to look at the filesystem.
 *
 * The harness discovers extensions, skills, prompt templates and context files
 * from its cwd and agent directory. Every one of those discoveries is disabled
 * explicitly in `createPiggySession`, but pointing cwd at the repo checkout
 * would mean a single missed flag puts source files into a CRM agent's prompt.
 * A dedicated directory outside the checkout makes that a non-event rather than
 * a leak, so the default is deliberately somewhere the deploy does not hold
 * code.
 */
const defaultAgentDir = join(homedir(), '.pig', 'piggy-agent');

/**
 * A blank environment variable means "not set", not "set to nothing".
 *
 * Compose passes an environment key listed in the bare form straight through
 * from `.env`, and a line reading `PIGGY_INFERENCE_API_KEY=` arrives as the
 * empty string rather than as an absent key. Against a plain
 * `.min(1).optional()` that is not absence — it is a value that fails the
 * length check — so a host with `PRIME_API_KEY` set perfectly well and a
 * leftover blank line for the legacy alias crash-looped at boot complaining
 * about the key the operator had never used. Coercing '' to undefined here is
 * the honest reading and it removes the whole class: the alias resolution
 * below then sees one key set and one absent, which is the supported case.
 */
function optionalSecret() {
  return z.preprocess(
    (value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
    z.string().min(1).optional(),
  );
}

/**
 * What one chat turn is allowed to cost, on both axes that can run away.
 *
 * The harness has no ceiling of its own: `agent-loop.js` in
 * `@earendil-works/pi-agent-core` runs `while (true)`, and the only things that
 * end it are the model declining to call another tool, an error, an abort, or
 * the `shouldStopAfterTurn` hook. A model that keeps asking for one more tool
 * call therefore keeps buying model calls until somebody stops it, and against
 * a fixed credit that is the whole credit. `PIGGY_MAX_TURNS` below looks like
 * this but is not: it belongs to the queue worker's own provider loop and never
 * reaches the harness.
 *
 * Both ceilings are needed because either alone is escapable. A call cap alone
 * still permits eight enormous calls; a token cap alone still permits a
 * thousand tiny ones, and each of those is a round trip that costs latency and
 * a minimum request charge even when it costs few tokens.
 *
 * The defaults are measured, not guessed, against the shipped default model on
 * the live dev stack:
 *
 *   one tool  (2 model calls)  4,798 in + 124 out =  4,922 tokens, $0.00026
 *   two tools (3 model calls) 12,099 in + 166 out = 12,265 tokens, $0.00064
 *
 * Input grows per call because every round trip resends the transcript and
 * every tool result so far, which is why the token ceiling is not simply the
 * call ceiling multiplied by one call's cost.
 *
 * 8 model calls is roughly two and a half times the busiest turn measured, so a
 * genuine multi-step question — search, read two records, propose a write,
 * summarise — fits with room over. It also bounds generation at
 * 8 x PIGGY_AGENT_MAX_TOKENS.
 *
 * 40,000 tokens is a little over three times the two-tool turn. On the default
 * model that is $0.002; on the most expensive model in the picker it is the
 * difference between a turn that costs pennies and one that costs a dollar.
 */
const turnLimitShape = {
  /**
   * Model round trips one chat turn may make, tool calls included. The turn
   * stops cleanly after this many rather than starting call N+1.
   */
  PIGGY_CHAT_MAX_MODEL_CALLS: z.coerce.number().int().positive().default(8),
  /**
   * Input plus output tokens one chat turn may consume across all its model
   * calls. Input is counted because it is billed: on a tool-heavy turn the
   * resent transcript is most of the money.
   */
  PIGGY_CHAT_MAX_TURN_TOKENS: z.coerce.number().int().positive().default(40_000),
  /**
   * Whole US cents one user may spend on Piggy in any rolling 24 hours, summed
   * from `agent_runs.cost_micro_cents`. 0 disables the ceiling.
   *
   * This sits on top of the relay's 30-messages-per-user-per-hour limiter,
   * which counts messages and therefore cannot see the difference between a
   * cheap model and an expensive one. 720 turns a day — the most that limiter
   * allows — costs about 46 cents on the default model, so $2 is out of reach
   * of any honest day's work there while still stopping someone from spending
   * the entire credit through the frontier models in the picker.
   */
  PIGGY_CHAT_DAILY_LIMIT_CENTS: z.coerce.number().int().nonnegative().default(200),
};

/**
 * How long a turn may say nothing at all before the server stops believing in
 * it.
 *
 * This is a guard that existed, was lost, and was then needed on the same day.
 * The hand-rolled chat loop had a 20,000ms deadline on an attempt's headers and
 * a 30,000ms idle deadline that restarted on every streamed chunk — deliberately
 * two deadlines rather than one, because a flat overall deadline kills a
 * legitimately long answer, and a long answer that is arriving is exactly the
 * turn worth protecting. Moving to the Prime Agent harness handed the HTTP call
 * to somebody else, and the guard did not come with it.
 *
 * Then `POST /chat/completions` began hanging. `GET /models` still answered in
 * 0.2s, so the endpoint was up and only the inference path was stalled or
 * throttling us; a bare `fetch` from Node ran past 180 seconds without settling.
 * The user saw the `meta` frame and then nothing, for ever, with the transcript
 * spinning until the browser gave up. The harness cannot help here: its
 * OpenAI-completions path passes a request timeout through only when the model
 * entry supplies one, and ours does not, so the fetch has no deadline of any
 * kind. Hence a deadline at the level the harness cannot swallow — the session's
 * own event stream, which the chat server already subscribes to.
 *
 * The two windows measure different silences and neither substitutes for the
 * other:
 *
 *   first progress — from `prompt()` to the first sign that the model is
 *                    working. It has to cover connecting, the endpoint's queue,
 *                    a slow frontier model's first token and any retry the
 *                    harness makes without announcing it. 60 seconds is three
 *                    times the old header deadline, which is the honest premium
 *                    for a harness whose internals we do not time.
 *   idle           — the longest gap between two signs of life once the turn is
 *                    under way. Mid-stream gaps are milliseconds; the widest
 *                    legitimate gap is a tool result followed by the next model
 *                    call's first token, and a retry the harness announces
 *                    resets this clock because an announced retry is an event.
 *                    45 seconds is half again the old idle deadline and well
 *                    past anything measured, and it resets on every event, so a
 *                    ten-minute answer that keeps arriving is never touched.
 *
 * Raising these is safe and cheap; the only thing they cost is how long a hung
 * socket holds a browser connection. Lowering them below the numbers above is
 * how a slow honest answer gets reported as a dead endpoint.
 */
const stallLimitShape = {
  /** Milliseconds from `prompt()` to the first sign the model is working. */
  PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000),
  /** Milliseconds of silence allowed between two events once a turn is moving. */
  PIGGY_CHAT_IDLE_TIMEOUT_MS: z.coerce.number().int().positive().default(45_000),
};

const baseSchema = z.object({
  DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
  /**
   * The one key. It serves both api.pinference.ai and the Prime platform API,
   * and `PIGGY_INFERENCE_API_KEY` is retained as an alias so a deploy that
   * predates the harness swap keeps starting. Both are optional here and the
   * "at least one" rule lives in the transform below, because a required field
   * would reject exactly the deployments the alias exists to protect.
   */
  PRIME_API_KEY: optionalSecret(),
  PIGGY_INFERENCE_API_KEY: optionalSecret(),
  PIGGY_INFERENCE_BASE: z.string().url().default('https://api.pinference.ai/api/v1'),
  PIGGY_MODEL: z.string().default('nvidia/nemotron-3-nano-30b-a3b'),
  /**
   * The model the agent answers with when the user has expressed no preference.
   * Constrained to the picker's catalogue rather than to the endpoint's 119
   * models: anything outside it is not registered with the harness, so it would
   * fail as an undefined model on the first turn instead of at startup.
   */
  PIGGY_AGENT_MODEL: z
    .string()
    .default(piggyDefaultModelId())
    .refine(isPiggyModelId, (value) => ({
      message: `${value} is not in the Piggy model catalogue (apps/piggy/src/agent/models.json).`,
    })),
  /**
   * Confirm, not read_only, is the shipped default. It is the mode in which
   * Piggy is useful and still cannot change anything without a person clicking:
   * a write is a proposal until it is approved. read_only remains the stronger
   * guarantee for a deployment that wants the pre-agent behaviour back.
   */
  PIGGY_AGENT_MODE: z.enum(PIGGY_MODES).default('confirm'),
  PIGGY_AGENT_DIR: z.string().min(1).default(defaultAgentDir),
  /**
   * Output tokens one agent turn may spend. Clamped down to the model's own
   * ceiling at session construction, so raising it here cannot ask a model for
   * more than it will give.
   */
  PIGGY_AGENT_MAX_TOKENS: z.coerce.number().int().positive().default(4_096),
  /*
   * How hard the model thinks before answering, and the single setting most
   * likely to make a working deployment look broken.
   *
   * The harness defaults this to `medium`, which is tuned for a coding agent
   * and is badly wrong here: on nemotron-nano that produced 6,195 output tokens
   * of reasoning and an EMPTY answer, because the turn hit its token ceiling
   * while still thinking (finish_reason `length`). `low` measured worse.
   * Reasoning bills as output, so that failure is expensive as well as useless.
   *
   * `off` is the default, and it is only half the fix. `off` alone makes the
   * harness OMIT `reasoning_effort` from the request entirely, so the
   * endpoint's own default wins and nothing changes; what actually turns the
   * reasoning off is the `thinkingLevelMap` on the nemotron entries in
   * agent/models.json, which maps `off` onto an explicit `"none"`. Measured
   * together: 149 output tokens and a correct answer for the same question.
   *
   * This is PER MODEL. A deployment that moves PIGGY_AGENT_MODEL to a model
   * with no `thinkingLevelMap` gets the endpoint's default back, whatever this
   * says.
   */
  PIGGY_AGENT_THINKING: z
    .enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
    .default('off'),
  ...turnLimitShape,
  ...stallLimitShape,
  PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300),
  PIGGY_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(2_000),
  PIGGY_MAX_TOKENS: z.coerce.number().int().positive().default(1_024),
  /**
   * The queued worker and the docked chat used to share one budget, which meant
   * raising it for a background extraction also raised it for every keystroke
   * in the panel. The chat gets its own, and a larger default: its tools return
   * aggregates the answer has to quote, and 1024 truncated mid-table.
   */
  PIGGY_CHAT_MAX_TOKENS: z.coerce.number().int().positive().default(2_048),
  /** Model calls one chat turn may make, tool round trips included. */
  PIGGY_MAX_TURNS: z.coerce.number().int().positive().default(4),
  /**
   * Left at 'none' deliberately. Reasoning tokens bill like any other and
   * nemotron-nano's are verbose; the docked panel is on every page, so the
   * volume is set by how often people type. Raise it only to make the UI's
   * reasoning panel reachable while debugging a wrong figure.
   */
  PIGGY_REASONING_EFFORT: z.enum(['none', 'low', 'medium', 'high']).default('none'),
  /**
   * Model price in cents per million tokens, which makes the cost arithmetic
   * exact in integers: micro-cents = tokens x cents-per-million. Defaults are
   * the published price of the default model, $0.05/$0.20 per Mtok, and must be
   * changed with it — a stale price here is worse than none, because it looks
   * like a measurement.
   */
  PIGGY_PRICE_INPUT_CENTS_PER_MTOK: z.coerce.number().nonnegative().default(5),
  PIGGY_PRICE_OUTPUT_CENTS_PER_MTOK: z.coerce.number().nonnegative().default(20),
  PIGGY_WORKER_ID: z.string().optional(),
  PIGGY_INTERNAL_TOKEN: z.string().min(32, 'PIGGY_INTERNAL_TOKEN must contain at least 32 characters.'),
  PIGGY_CHAT_HOST: z.string().default('127.0.0.1'),
  PIGGY_CHAT_PORT: z.coerce.number().int().positive().default(8_931),
  PIGGY_CHAT_ALLOW_NON_LOOPBACK: z
    .enum(['true', 'false'])
    .default('false')
    .transform((value) => value === 'true'),
});

/**
 * Resolves the two spellings of the key into one value the rest of the app can
 * read without knowing which spelling the deploy used. Both names are then set
 * to the resolved key so the pre-agent call sites keep compiling and keep
 * working.
 */
const schema = baseSchema.transform((env, ctx) => {
  const primeApiKey = env.PRIME_API_KEY ?? env.PIGGY_INFERENCE_API_KEY;
  if (!primeApiKey) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ['PRIME_API_KEY'],
      message:
        'is required. It serves both Prime Inference and the platform API. PIGGY_INFERENCE_API_KEY is still accepted as the legacy alias.',
    });
    return z.NEVER;
  }
  return {
    ...env,
    PRIME_API_KEY: primeApiKey,
    PIGGY_INFERENCE_API_KEY: primeApiKey,
  };
});

export type PiggyConfig = z.infer<typeof schema> & { workerId: string };

/** The ceilings one chat turn is measured against, in the units it counts in. */
export interface PiggyTurnLimits {
  maxModelCalls: number;
  /** Input plus output, summed over every model call in the turn. */
  maxTurnTokens: number;
  /** Whole US cents per user per rolling 24 hours. 0 disables the ceiling. */
  dailyLimitCents: number;
}

/**
 * The two silences a turn is allowed, in milliseconds.
 *
 * Separate from `PiggyTurnLimits` because they answer a different question.
 * Those ceilings ask what a turn may spend and are counted in model calls and
 * tokens; these ask whether the turn is alive at all and are counted in
 * wall-clock. Merging them would invite a future reader to bound a turn's
 * duration the way its cost is bounded, which is precisely the flat deadline
 * both of these exist to avoid.
 */
export interface PiggyStallLimits {
  /** From `prompt()` to the first sign the model is working. */
  firstProgressMs: number;
  /** The longest silence allowed between two events once the turn is moving. */
  idleMs: number;
}

/**
 * The stall deadlines alone, parsed without the rest of the environment, for
 * the same reason `loadPiggyTurnLimits` exists: the chat server is constructed
 * directly by the tests and must not need a DATABASE_URL to hold a deadline.
 */
export function loadPiggyStallLimits(env: NodeJS.ProcessEnv = process.env): PiggyStallLimits {
  const parsed = z.object(stallLimitShape).safeParse(env);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((issue) => `  ${issue.path.join('.')}: ${issue.message}`);
    throw new Error(`Invalid Piggy stall deadlines:\n${issues.join('\n')}`);
  }
  return {
    firstProgressMs: parsed.data.PIGGY_CHAT_FIRST_PROGRESS_TIMEOUT_MS,
    idleMs: parsed.data.PIGGY_CHAT_IDLE_TIMEOUT_MS,
  };
}

/**
 * The turn ceilings alone, parsed without the rest of the environment.
 *
 * `startPiggyChatServer` is handed a socket and a token and builds everything
 * else from defaults, and it is constructed directly by the tests. Reaching for
 * `loadPiggyConfig` there would make the chat server refuse to start without a
 * DATABASE_URL and a live API key it does not itself use. The same three fields
 * are in the full schema, so `main.ts` still fails at boot — with the message
 * naming the variable — on a deployment that mistypes one.
 */
export function loadPiggyTurnLimits(env: NodeJS.ProcessEnv = process.env): PiggyTurnLimits {
  const parsed = z.object(turnLimitShape).safeParse(env);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((issue) => `  ${issue.path.join('.')}: ${issue.message}`);
    throw new Error(`Invalid Piggy turn limits:\n${issues.join('\n')}`);
  }
  return {
    maxModelCalls: parsed.data.PIGGY_CHAT_MAX_MODEL_CALLS,
    maxTurnTokens: parsed.data.PIGGY_CHAT_MAX_TURN_TOKENS,
    dailyLimitCents: parsed.data.PIGGY_CHAT_DAILY_LIMIT_CENTS,
  };
}

export function loadPiggyConfig(env: NodeJS.ProcessEnv = process.env): PiggyConfig {
  const parsed = schema.safeParse(env);
  if (!parsed.success) {
    const issues = parsed.error.issues.map((issue) => `  ${issue.path.join('.')}: ${issue.message}`);
    throw new Error(`Invalid Piggy configuration:\n${issues.join('\n')}`);
  }

  return {
    ...parsed.data,
    workerId: parsed.data.PIGGY_WORKER_ID ?? `${hostname()}:${process.pid}`,
  };
}
