/**
 * 開発用の偽制御盤（指示書AY 段階11-3。D23・D24）。
 *
 * D-Watch の `/api/ds-agent/` をポーリングし、list / export / send に固定の応答を返す。
 * ★本物の常駐プログラム（`dwatch-log-agent`）の代わりにはならない★ — 非 fixtures の通し確認用。
 *
 * 実行例（トークンは画面で発行した値。★値をログに出さない★）:
 *   $env:DS_AGENT_TOKEN='…'
 *   npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip
 *   npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip --fail-prepare too_large
 *
 * Args: --server <url> --zip <path> [--fail-prepare <code>]
 * Env:  DS_AGENT_TOKEN（必須。Bearer）
 */
import { createHash } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";

const AGENT_VERSION = "fake-0.1.0";
const POLL_WAIT_SEC = 25;
/** チャンク POST のタイムアウト（秒。D20。設計書と同じ） */
const CHUNK_POST_TIMEOUT_SEC = 60;
const CHUNK_MAX_BYTES = 4 * 1024 * 1024;
const USER_AGENT = `dwatch-ds-agent-fake/${AGENT_VERSION}`;

const LOG_TYPES = [
  { key: "api_client", label: "API 通信（報告間隔の取得・通信の有効/無効）（仮）" },
  { key: "postcondition_security", label: "セキュリティ状態の報告（仮）" },
  { key: "postcondition_kick_action", label: "セコム状態の監視・清掃の起動（仮）" },
  { key: "send_start_sig", label: "清掃開始信号（仮）" },
] as const;

type Command =
  | { command_id: string; kind: "list"; params: Record<string, unknown> }
  | {
      command_id: string;
      kind: "export";
      params: { file_ids?: string[]; max_bytes?: number; chunk_bytes?: number };
    }
  | { command_id: string; kind: "send"; params: { export_command_id?: string } };

type Args = {
  server: string;
  zipPath: string;
  failPrepare: string | null;
};

function usage(message?: string): never {
  if (message) console.error(`[ds-agent-fake] ${message}`);
  console.error(
    "Usage: npx tsx scripts/ds-agent-fake.ts --server <url> --zip <path> [--fail-prepare <code>]\n" +
      "  Env: DS_AGENT_TOKEN (required; never logged)"
  );
  process.exit(1);
}

function parseArgs(argv: string[]): Args {
  let server: string | null = null;
  let zipPath: string | null = null;
  let failPrepare: string | null = null;
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    const next = argv[i + 1];
    if (a === "--server" && next) {
      server = next;
      i++;
    } else if (a === "--zip" && next) {
      zipPath = next;
      i++;
    } else if (a === "--fail-prepare" && next) {
      failPrepare = next;
      i++;
    } else if (a === "--help" || a === "-h") {
      usage();
    } else {
      usage(`unknown argument: ${a}`);
    }
  }
  if (!server) usage("--server is required");
  if (!zipPath) usage("--zip is required");
  return { server: server.replace(/\/$/, ""), zipPath: resolve(zipPath), failPrepare };
}

/** JST 壁時計の ISO（+09:00）。modified_at はオフセット必須 */
function toJstIso(date: Date): string {
  const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000);
  const y = jst.getUTCFullYear();
  const m = String(jst.getUTCMonth() + 1).padStart(2, "0");
  const d = String(jst.getUTCDate()).padStart(2, "0");
  const hh = String(jst.getUTCHours()).padStart(2, "0");
  const mm = String(jst.getUTCMinutes()).padStart(2, "0");
  const ss = String(jst.getUTCSeconds()).padStart(2, "0");
  return `${y}-${m}-${d}T${hh}:${mm}:${ss}+09:00`;
}

function sha256Hex(bytes: Uint8Array | Buffer): string {
  return createHash("sha256").update(bytes).digest("hex");
}

/** 例外メッセージにトークンが混ざっていたら伏せる（値は絶対に出さない） */
function safeError(err: unknown, token: string): string {
  const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
  return token && text.includes(token) ? text.split(token).join("***") : text;
}

/**
 * 固定の一覧（約10件。実機の種類・name の形。B-9・M6）。
 * name は `{root の name}/{相対パス}` = `<種類>/<種類>.log` と日付つき。
 */
function fixedListResult(now: Date) {
  const nowMs = now.getTime();
  const day = 24 * 60 * 60 * 1000;
  const items: Array<{
    file_id: string;
    name: string;
    type: string;
    size_bytes: number;
    modified_at: string;
  }> = [];
  let n = 0;
  const push = (type: string, name: string, modifiedMs: number, size: number) => {
    n += 1;
    items.push({
      file_id: `fake-${n}`,
      name,
      type,
      size_bytes: size,
      modified_at: toJstIso(new Date(modifiedMs)),
    });
  };

  // 各種類の最新
  for (const t of LOG_TYPES) {
    push(t.key, `${t.key}/${t.key}.log`, nowMs - items.length * 60_000, 4096 + items.length * 512);
  }
  // 日付つき（各種類1日分 + 追加2件で約10件）
  const dates = [1, 2].map((d) => toJstIso(new Date(nowMs - d * day)).slice(0, 10));
  push(
    "api_client",
    `api_client/api_client.log.${dates[0]}`,
    nowMs - day,
    8192
  );
  push(
    "postcondition_security",
    `postcondition_security/postcondition_security.log.${dates[0]}`,
    nowMs - day - 60_000,
    12288
  );
  push(
    "postcondition_kick_action",
    `postcondition_kick_action/postcondition_kick_action.log.${dates[0]}`,
    nowMs - day - 120_000,
    16384
  );
  push(
    "send_start_sig",
    `send_start_sig/send_start_sig.log.${dates[0]}`,
    nowMs - day - 180_000,
    20480
  );
  push(
    "api_client",
    `api_client/api_client.log.${dates[1]}`,
    nowMs - 2 * day,
    3072
  );
  push(
    "postcondition_security",
    `postcondition_security/postcondition_security.log.${dates[1]}`,
    nowMs - 2 * day - 60_000,
    6144
  );

  return {
    types: LOG_TYPES.map((t) => ({ key: t.key, label: t.label })),
    items,
    truncated: false as const,
  };
}

class FakeAgent {
  private readonly base: string;
  private readonly token: string;
  private readonly zipBytes: Buffer;
  private readonly zipSha256: string;
  private readonly failPrepare: string | null;
  /** export の command_id → 使った chunk_bytes（send で参照） */
  private readonly preparedChunkBytes = new Map<string, number>();
  private stopping = false;

  constructor(opts: { server: string; token: string; zipPath: string; failPrepare: string | null }) {
    this.base = opts.server;
    this.token = opts.token;
    this.failPrepare = opts.failPrepare;
    const st = statSync(opts.zipPath);
    if (!st.isFile()) throw new Error(`--zip is not a file: ${opts.zipPath}`);
    this.zipBytes = readFileSync(opts.zipPath);
    this.zipSha256 = sha256Hex(this.zipBytes);
    console.log(
      `[ds-agent-fake] zip loaded path=${opts.zipPath} bytes=${this.zipBytes.length} sha256=${this.zipSha256}`
    );
    if (opts.failPrepare) {
      console.log(`[ds-agent-fake] --fail-prepare ${opts.failPrepare} (export will fail)`);
    }
  }

  private headers(extra?: Record<string, string>): Record<string, string> {
    return {
      Authorization: `Bearer ${this.token}`,
      "X-Agent-Version": AGENT_VERSION,
      "User-Agent": USER_AGENT,
      ...extra,
    };
  }

  private commandUrl(commandId: string, ...parts: Array<string | number>): string {
    const enc = encodeURIComponent(commandId);
    const rest = parts.map((p) => String(p)).join("/");
    return `${this.base}/api/ds-agent/commands/${enc}/${rest}`;
  }

  private async request(
    method: string,
    url: string,
    init?: { body?: BodyInit | null; headers?: Record<string, string>; timeoutMs?: number }
  ): Promise<Response> {
    const signal =
      init?.timeoutMs !== undefined ? AbortSignal.timeout(init.timeoutMs) : undefined;
    return fetch(url, {
      method,
      headers: this.headers(init?.headers),
      body: init?.body ?? null,
      redirect: "manual",
      signal,
    });
  }

  private async postJson(commandId: string, action: "result" | "prepared" | "complete" | "fail", body: unknown) {
    const res = await this.request("POST", this.commandUrl(commandId, action), {
      body: JSON.stringify(body),
      headers: { "Content-Type": "application/json" },
      timeoutMs: 30_000,
    });
    return res.status;
  }

  async handle(cmd: Command): Promise<void> {
    switch (cmd.kind) {
      case "list":
        await this.doList(cmd.command_id);
        return;
      case "export":
        await this.doExport(cmd);
        return;
      case "send":
        await this.doSend(cmd);
        return;
      default: {
        const unknown = cmd as { command_id?: string; kind?: string };
        if (unknown.command_id) {
          await this.postJson(unknown.command_id, "fail", {
            code: "unsupported",
            message: `unknown kind: ${String(unknown.kind)}`,
          });
        }
      }
    }
  }

  private async doList(commandId: string): Promise<void> {
    const status = await this.postJson(commandId, "result", fixedListResult(new Date()));
    console.log(`[ds-agent-fake] list result status=${status} items≈10`);
  }

  private async doExport(cmd: Extract<Command, { kind: "export" }>): Promise<void> {
    if (this.failPrepare) {
      const status = await this.postJson(cmd.command_id, "fail", {
        code: this.failPrepare,
        message: `fake prepare failed: ${this.failPrepare}`,
      });
      console.log(`[ds-agent-fake] export fail code=${this.failPrepare} status=${status}`);
      return;
    }

    const fileIds = Array.isArray(cmd.params.file_ids) ? cmd.params.file_ids : [];
    const fileCount = Math.max(1, fileIds.length);
    const chunkBytes =
      typeof cmd.params.chunk_bytes === "number" && cmd.params.chunk_bytes > 0
        ? Math.min(cmd.params.chunk_bytes, CHUNK_MAX_BYTES)
        : CHUNK_MAX_BYTES;

    const status = await this.postJson(cmd.command_id, "prepared", {
      total_bytes: this.zipBytes.length,
      sha256: this.zipSha256,
      file_count: fileCount,
    });
    if (status === 204) {
      this.preparedChunkBytes.set(cmd.command_id, chunkBytes);
    }
    console.log(
      `[ds-agent-fake] export prepared status=${status} bytes=${this.zipBytes.length} files=${fileCount}`
    );
  }

  private async doSend(cmd: Extract<Command, { kind: "send" }>): Promise<void> {
    const exportId = cmd.params.export_command_id;
    if (typeof exportId !== "string" || exportId.length === 0) {
      await this.postJson(cmd.command_id, "fail", {
        code: "export_not_found",
        message: "export_command_id missing",
      });
      console.log("[ds-agent-fake] send fail: missing export_command_id");
      return;
    }

    const chunkBytes = this.preparedChunkBytes.get(exportId) ?? CHUNK_MAX_BYTES;
    let seq = 0;
    for (let offset = 0; offset < this.zipBytes.length; offset += chunkBytes, seq++) {
      const chunk = Uint8Array.from(this.zipBytes.subarray(offset, offset + chunkBytes));
      const chunkSha = sha256Hex(chunk);
      let res: Response;
      try {
        res = await this.request("POST", this.commandUrl(exportId, "chunks", seq), {
          body: chunk,
          headers: {
            "Content-Type": "application/octet-stream",
            "X-Chunk-Sha256": chunkSha,
          },
          timeoutMs: CHUNK_POST_TIMEOUT_SEC * 1000,
        });
      } catch (err) {
        console.error(`[ds-agent-fake] chunk ${seq} network error: ${safeError(err, this.token)}`);
        await this.postJson(exportId, "fail", {
          code: "io_error",
          message: `chunk ${seq} upload failed`,
        });
        return;
      }

      if (res.status === 204) {
        console.log(`[ds-agent-fake] chunk seq=${seq} bytes=${chunk.length} ok`);
        continue;
      }
      if (res.status === 410 || res.status === 409) {
        console.log(`[ds-agent-fake] chunk seq=${seq} stop status=${res.status}`);
        return;
      }
      console.error(`[ds-agent-fake] chunk seq=${seq} rejected status=${res.status}`);
      await this.postJson(exportId, "fail", {
        code: "io_error",
        message: `chunk ${seq} status ${res.status}`,
      });
      return;
    }

    const status = await this.postJson(exportId, "complete", {
      total_bytes: this.zipBytes.length,
      sha256: this.zipSha256,
    });
    this.preparedChunkBytes.delete(exportId);
    console.log(`[ds-agent-fake] complete status=${status} chunks=${seq}`);
  }

  async run(): Promise<void> {
    console.log(`[ds-agent-fake] polling ${this.base}/api/ds-agent/commands (version=${AGENT_VERSION})`);
    let backoffSec = 1;
    while (!this.stopping) {
      const url = `${this.base}/api/ds-agent/commands?wait=${POLL_WAIT_SEC}`;
      let res: Response;
      try {
        res = await this.request("GET", url, {
          timeoutMs: (POLL_WAIT_SEC + 10) * 1000,
        });
      } catch (err) {
        if (this.stopping) return;
        console.warn(
          `[ds-agent-fake] poll failed: ${safeError(err, this.token)} (retry in ${backoffSec}s)`
        );
        await sleep(backoffSec * 1000);
        backoffSec = Math.min(backoffSec * 2, 30);
        continue;
      }

      if (res.status === 200) {
        backoffSec = 1;
        let cmd: Command;
        try {
          cmd = (await res.json()) as Command;
        } catch (err) {
          console.error(`[ds-agent-fake] poll body not JSON: ${safeError(err, this.token)}`);
          await sleep(backoffSec * 1000);
          backoffSec = Math.min(backoffSec * 2, 30);
          continue;
        }
        console.log(`[ds-agent-fake] command kind=${cmd.kind} id=${cmd.command_id}`);
        // list はエクスポートと並行して返す（本物と同じ。D25 ③）
        void this.handle(cmd).catch((err) => {
          console.error(`[ds-agent-fake] handle error: ${safeError(err, this.token)}`);
        });
        continue;
      }

      if (res.status === 204) {
        backoffSec = 1;
        continue;
      }

      if (res.status === 401 || res.status === 403) {
        console.error(
          `[ds-agent-fake] poll rejected status=${res.status} (token wrong/revoked or IP). retry in 30s`
        );
        await sleep(30_000);
        continue;
      }

      console.warn(`[ds-agent-fake] poll status=${res.status} (retry in ${backoffSec}s)`);
      await sleep(backoffSec * 1000);
      backoffSec = Math.min(backoffSec * 2, 30);
    }
  }

  stop(): void {
    this.stopping = true;
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2));
  const token = process.env.DS_AGENT_TOKEN;
  if (!token || token.trim() === "") {
    usage("DS_AGENT_TOKEN env is required");
  }

  const agent = new FakeAgent({
    server: args.server,
    token,
    zipPath: args.zipPath,
    failPrepare: args.failPrepare,
  });

  const onSignal = () => {
    console.log("[ds-agent-fake] stopping…");
    agent.stop();
  };
  process.on("SIGINT", onSignal);
  process.on("SIGTERM", onSignal);

  await agent.run();
}

main().catch((err) => {
  // 起動前にトークンを読む前の失敗もあるので、値は出さない
  const msg = err instanceof Error ? err.message : String(err);
  console.error(`[ds-agent-fake] fatal: ${msg}`);
  process.exit(1);
});
