/**
 * 制御盤のログの API の検証（指示書AY 段階3-3・段階7-2。§11-4 M7・M8）。
 * 期待値の正は段階3-3 の V1〜V10・X1〜X6 と段階7-2 の W1〜W11・指示書BJ の R45〜R48（`__tests__/validate.test.ts`）。
 *
 * - ★ブラウザ向け（クエリ・本体・パス）の誤りは 422 `validation_failed`★（D17）。
 *   ただし `page` / `per_page` は 422 にせず丸める・空文字のパラメータは省略と同じ（既存の一覧 API と同じ作法。M7）。
 * - ★制御盤から来た JSON（result / prepared / complete / fail）の形の誤りは、すべて 400 `result_invalid`★（D25 ④）。
 *   ★本体は生の文字列で受け取る★ — JSON でない・オブジェクトでない（`null`・配列・数値・文字列）も同じ 400（R43・R44）。
 *
 * ★純粋関数★（`CLAUDE.md` §9.4）。★入力値をエラー文言にエコーしない★（`lib/api/path-params.ts` と同じ考え方）。
 */
import { isJsonObject } from "@/lib/api/json-body";
import { fail, type QueryParseResult } from "@/lib/api/query-result";
import { parseDate } from "@/lib/clean-logs/query";
import { PAGINATION } from "@/lib/config";
import {
  AGENT_POLL_WAIT_SEC,
  DS_LOG_DEFAULT_PER_PAGE,
  EXPORT_FILE_IDS_MAX_BYTES,
  EXPORT_MAX_FILES,
  LIST_RESULT_MAX_BYTES,
  LIST_RESULT_MAX_ITEMS,
  PATH_COMMAND_ID_MAX_LENGTH,
  PATH_EXPORT_ID_MAX_LENGTH,
} from "@/lib/config/ds-agent";
import type { DeviceLogQuery } from "@/lib/ds-logs/query";
import type {
  DsAgentComplete,
  DsAgentFail,
  DsAgentListResult,
  DsAgentPrepared,
  DsLogFile,
  DsLogSort,
  DsLogType,
} from "@/lib/types";

const SORTS: readonly DsLogSort[] = ["date_desc", "type_date_desc"];

/**
 * `file_id` の長さの上限（一覧・エクスポート共通）。
 * 制御盤の常駐プログラムが受け取れる上限 `FILE_ID_MAX_LEN`（`agent.py:64`。`decode_file_id()` が使う）とそろえる。指示書BK K6
 */
export const FILE_ID_MAX_LENGTH = 8192;
/** 制御盤の失敗の報告の `code` / `message` の長さ（段階2 の `DsAgentFail`。制御盤側も同じ値） */
const AGENT_CODE_MAX_LENGTH = 64;
const AGENT_MESSAGE_MAX_LENGTH = 500;

const SHA256_HEX = /^[0-9a-f]{64}$/;
/** `YYYY-MM-DDTHH:MM:SS` ＋ ★オフセット必須★（`Z` か `±HH:MM`）。小数秒は受けない */
const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
/** 0 を許す整数。★先頭の 0・符号・小数・空白を通さない・9桁まで★（X4〜X6） */
const SEQ = /^(0|[1-9][0-9]{0,8})$/;

const INVALID_JSON_MESSAGE = "JSONの形式が不正です";

// ── 共通 ──

/** 空白だけの値は省略と同じ（BH の作法。V4） */
function presentParam(searchParams: URLSearchParams, name: string): string | null {
  const value = searchParams.get(name);
  if (value === null || value.trim() === "") return null;
  return value;
}

function toInt(value: string | null, fallback: number): number {
  if (!value) return fallback;
  const n = Number.parseInt(value, 10);
  return Number.isNaN(n) ? fallback : n;
}

/**
 * オフセット付きの ISO 8601 を瞬間（ms）にする。形が違う・存在しない日付なら `null`。
 * ★`Date.parse("2026-02-30T00:00:00+09:00")` は NaN にならず 3/2 になる★ので、日付の部分（先頭10文字）を
 * `parseDate()` に通して確かめる（4-24）。★`toJstIso()` で戻して全体を比べない★（`Z` など正しい入力まで弾く）。
 */
function parseInstant(value: string): number | null {
  if (!ISO_WITH_OFFSET.test(value)) return null;
  if (parseDate(value.slice(0, 10)) === null) return null;
  const hh = Number(value.slice(11, 13));
  const mm = Number(value.slice(14, 16));
  const ss = Number(value.slice(17, 19));
  if (hh > 23 || mm > 59 || ss > 59) return null;
  const ms = Date.parse(value);
  return Number.isNaN(ms) ? null : ms;
}

function isNonNegativeInteger(value: unknown): value is number {
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}

function isStringOfLength(value: unknown, min: number, max: number): value is string {
  return typeof value === "string" && value.length >= min && value.length <= max;
}

function parseJsonObject(text: string): Record<string, unknown> | null {
  let body: unknown;
  try {
    body = JSON.parse(text);
  } catch {
    return null;
  }
  return isJsonObject(body) ? body : null;
}

function resultInvalid(message: string) {
  return fail(400, "result_invalid", message);
}

// ── ブラウザ向け（段階3-3）──

/**
 * `GET /ds-devices/{serial_number}/logs` のクエリ（V1〜V6）。
 * `page` / `per_page` は `lib/ds-devices/query.ts` と同じく丸める（`per_page` の既定は `DS_LOG_DEFAULT_PER_PAGE`）。
 */
export function parseDeviceLogQuery(searchParams: URLSearchParams): QueryParseResult<DeviceLogQuery> {
  let types: string[] | null = null;
  const typesRaw = presentParam(searchParams, "types");
  if (typesRaw !== null) {
    types = typesRaw.split(",");
    // ★全体が空は省略と同じ（V4）だが、並びの中の空の要素は打ち間違いなので通さない（V4b）★
    if (types.some((t) => t === "")) {
      return fail(422, "validation_failed", "typesの形式が不正です", {
        types: ["カンマ区切りの種類のキーを指定してください（空の要素は不可）"],
      });
    }
  }

  const fromRaw = presentParam(searchParams, "from");
  const from = fromRaw === null ? null : parseInstant(fromRaw);
  if (fromRaw !== null && from === null) {
    return fail(422, "validation_failed", "fromの形式が不正です", {
      from: ["オフセット付きの ISO 8601（例 2026-10-19T11:00:00+09:00）で、存在する日時を指定してください"],
    });
  }
  const toRaw = presentParam(searchParams, "to");
  const to = toRaw === null ? null : parseInstant(toRaw);
  if (toRaw !== null && to === null) {
    return fail(422, "validation_failed", "toの形式が不正です", {
      to: ["オフセット付きの ISO 8601（例 2026-10-19T12:00:00+09:00）で、存在する日時を指定してください"],
    });
  }
  if (from !== null && to !== null && from > to) {
    return fail(422, "validation_failed", "fromはto以前にしてください", {
      from: ["to 以前の日時を指定してください"],
    });
  }

  const sortRaw = presentParam(searchParams, "sort") ?? "date_desc";
  if (!SORTS.includes(sortRaw as DsLogSort)) {
    return fail(422, "validation_failed", "sortの値が不正です", {
      sort: [`${SORTS.join(" / ")} のいずれかを指定してください`],
    });
  }

  const page = Math.max(1, toInt(searchParams.get("page"), 1));
  const perPage = Math.min(
    PAGINATION.maxPerPage,
    Math.max(1, toInt(searchParams.get("per_page"), DS_LOG_DEFAULT_PER_PAGE))
  );

  return { ok: true, query: { types, from, to, sort: sortRaw as DsLogSort, page, perPage } };
}

/** エクスポートで選んだ ID の合計が大きすぎるときの message（指示書BK K7。画面の文言と共有） */
export const EXPORT_FILE_IDS_TOO_LARGE_MESSAGE = "選んだファイルが多すぎます。数を減らしてください";

/**
 * `POST /ds-devices/{serial_number}/logs/exports` の本体（V7〜V9）。生の文字列で受け取る。
 * JSON でない・オブジェクトでない本体は 422「JSONの形式が不正です」（note と同じ文。M7 (d)）。
 * ★ID の合計の上限（K7）★: `file_ids` を JSON にした UTF-8 バイト数が `EXPORT_FILE_IDS_MAX_BYTES` を超えたら 422。
 */
export function parseExportRequest(text: string): QueryParseResult<{ fileIds: string[] }> {
  const body = parseJsonObject(text);
  if (body === null) return fail(422, "validation_failed", INVALID_JSON_MESSAGE);

  const fileIds = body.file_ids;
  const invalid = () =>
    fail(422, "validation_failed", "file_idsの形式が不正です", {
      file_ids: [
        `1〜${EXPORT_MAX_FILES}件の重複しない文字列（各1〜${FILE_ID_MAX_LENGTH}文字）の配列で指定してください`,
      ],
    });
  if (!Array.isArray(fileIds) || fileIds.length < 1 || fileIds.length > EXPORT_MAX_FILES) return invalid();
  if (!fileIds.every((id) => isStringOfLength(id, 1, FILE_ID_MAX_LENGTH))) return invalid();
  if (new Set(fileIds).size !== fileIds.length) return invalid();
  // ★件数・各 ID・重複のあとで合計を見る★（先に見ると V7 の既存行の文言と食い違う）
  if (new TextEncoder().encode(JSON.stringify(fileIds)).length > EXPORT_FILE_IDS_MAX_BYTES) {
    return fail(422, "validation_failed", EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, {
      file_ids: [
        `ファイル ID の合計が ${EXPORT_FILE_IDS_MAX_BYTES} バイト（JSON・UTF-8）以内になるように選んでください`,
      ],
    });
  }
  return { ok: true, query: { fileIds: fileIds as string[] } };
}

/**
 * 制御盤向け `GET /ds-agent/commands` の `wait`（V10・表5 R8）。省略は `AGENT_POLL_WAIT_SEC`。
 * ★範囲外・数でない値は丸めずに 422★（制御盤のプログラムの誤りを早く見つけるため）。
 */
export function parseAgentPollWait(value: string | null): QueryParseResult<number> {
  if (value === null) return { ok: true, query: AGENT_POLL_WAIT_SEC };
  if (/^[1-9][0-9]*$/.test(value)) {
    const n = Number(value);
    if (n <= AGENT_POLL_WAIT_SEC) return { ok: true, query: n };
  }
  return fail(422, "validation_failed", "waitの値が不正です", {
    wait: [`1〜${AGENT_POLL_WAIT_SEC}の整数で指定してください`],
  });
}

// ── パスパラメータ（X1〜X6。M8）──

/**
 * `export_id`（X1〜X3）。★`parsePathReportId()` と同じ作法★ — 空と上限超だけ 422・文字種は絞らない・`Number()` を通さない。
 */
export function parsePathExportId(value: string): QueryParseResult<string> {
  if (value.length === 0 || value.length > PATH_EXPORT_ID_MAX_LENGTH) {
    return fail(422, "validation_failed", "export_idの形式が不正です", {
      export_id: [`${PATH_EXPORT_ID_MAX_LENGTH}文字以内で指定してください`],
    });
  }
  return { ok: true, query: value };
}

/** `command_id`（X1〜X3）。`parsePathExportId()` と同じ作法 */
export function parsePathCommandId(value: string): QueryParseResult<string> {
  if (value.length === 0 || value.length > PATH_COMMAND_ID_MAX_LENGTH) {
    return fail(422, "validation_failed", "command_idの形式が不正です", {
      command_id: [`${PATH_COMMAND_ID_MAX_LENGTH}文字以内で指定してください`],
    });
  }
  return { ok: true, query: value };
}

/**
 * チャンクの `seq`（X4〜X6）。★0 を許す★ので `parsePathId()`（1以上）は使わない。
 * 9桁まで（4MB × 10億 は 100MB の上限より十分大きい）。
 */
export function parsePathSeq(value: string): QueryParseResult<number> {
  if (!SEQ.test(value)) {
    return fail(422, "validation_failed", "seqの形式が不正です", {
      seq: ["0以上の整数（9桁まで・先頭の0なし）で指定してください"],
    });
  }
  return { ok: true, query: Number(value) };
}

// ── 制御盤から来た本体（段階7-2。形の誤りはすべて 400 result_invalid）──

/** 一覧の `types[].key`: 1文字以上・`,` を含まない・前後に空白が無い（指示書BJ J5） */
function isLogTypeKey(value: unknown): value is string {
  return typeof value === "string" && value.length >= 1 && !value.includes(",") && value.trim() === value;
}

function isLogType(value: unknown): value is DsLogType {
  return isJsonObject(value) && isLogTypeKey(value.key) && typeof value.label === "string";
}

function isLogFile(value: unknown, typeKeys: ReadonlySet<string>): value is DsLogFile {
  if (!isJsonObject(value)) return false;
  return (
    typeof value.file_id === "string" &&
    isStringOfLength(value.file_id, 1, FILE_ID_MAX_LENGTH) &&
    typeof value.name === "string" &&
    typeof value.type === "string" &&
    typeKeys.has(value.type) &&
    isNonNegativeInteger(value.size_bytes) &&
    typeof value.modified_at === "string" &&
    parseInstant(value.modified_at) !== null
  );
}

/**
 * 一覧の結果（W1〜W11）。★1件でも形が違えば結果全体を不正とする★（黙って落とさない）。
 * ★本体の大きさは読んだ文字列の UTF-8 のバイト数で判定する★（`Content-Length` が無くても。W8）。
 * `{ error: { code, message } }` は失敗の報告として受理する（W9。中継側で failCommand と同じ扱い）。
 * `name` は `{root の name}/{相対パス}` で、★`/` を理由に弾かない・中身を解析しない★（W11）。
 */
export function parseAgentListResult(text: string): QueryParseResult<DsAgentListResult> {
  if (new TextEncoder().encode(text).length > LIST_RESULT_MAX_BYTES) {
    return resultInvalid("一覧の結果が大きすぎます");
  }
  const body = parseJsonObject(text);
  if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);

  if ("error" in body) {
    const error = body.error;
    if (
      isJsonObject(error) &&
      isStringOfLength(error.code, 1, AGENT_CODE_MAX_LENGTH) &&
      isStringOfLength(error.message, 1, AGENT_MESSAGE_MAX_LENGTH)
    ) {
      return { ok: true, query: { error: { code: error.code, message: error.message } } };
    }
    return resultInvalid("失敗の報告の形式が不正です");
  }

  const { types, items, truncated } = body;
  if (!Array.isArray(types) || !types.every(isLogType)) return resultInvalid("typesの形式が不正です");
  const typeKeys = new Set(types.map((t) => t.key));
  if (typeKeys.size !== types.length) return resultInvalid("typesのkeyが重複しています");
  if (typeof truncated !== "boolean") return resultInvalid("truncatedの形式が不正です");
  if (!Array.isArray(items) || items.length > LIST_RESULT_MAX_ITEMS) return resultInvalid("itemsの形式が不正です");
  if (!items.every((item) => isLogFile(item, typeKeys))) return resultInvalid("itemsの形式が不正です");
  if (new Set(items.map((item) => item.file_id)).size !== items.length) {
    return resultInvalid("itemsのfile_idが重複しています");
  }

  return {
    ok: true,
    query: {
      types: types.map((t) => ({ key: t.key, label: t.label })),
      items: items.map((item) => ({
        file_id: item.file_id,
        name: item.name,
        type: item.type,
        size_bytes: item.size_bytes,
        modified_at: item.modified_at,
      })),
      truncated,
    },
  };
}

/** prepared の本体（`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁・`file_count` は1以上の整数） */
export function parseAgentPrepared(text: string): QueryParseResult<DsAgentPrepared> {
  const body = parseJsonObject(text);
  if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
  const { total_bytes, sha256, file_count } = body;
  if (
    !isNonNegativeInteger(total_bytes) ||
    total_bytes < 1 ||
    typeof sha256 !== "string" ||
    !SHA256_HEX.test(sha256) ||
    !isNonNegativeInteger(file_count) ||
    file_count < 1
  ) {
    return resultInvalid("preparedの形式が不正です");
  }
  return { ok: true, query: { total_bytes, sha256, file_count } };
}

/** complete の本体（`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁）。値の照合は中継側（D15） */
export function parseAgentComplete(text: string): QueryParseResult<DsAgentComplete> {
  const body = parseJsonObject(text);
  if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
  const { total_bytes, sha256 } = body;
  if (
    !isNonNegativeInteger(total_bytes) ||
    total_bytes < 1 ||
    typeof sha256 !== "string" ||
    !SHA256_HEX.test(sha256)
  ) {
    return resultInvalid("completeの形式が不正です");
  }
  return { ok: true, query: { total_bytes, sha256 } };
}

/** fail の本体（`code` は1〜64文字・`message` は1〜500文字） */
export function parseAgentFail(text: string): QueryParseResult<DsAgentFail> {
  const body = parseJsonObject(text);
  if (body === null) return resultInvalid(INVALID_JSON_MESSAGE);
  const { code, message } = body;
  if (!isStringOfLength(code, 1, AGENT_CODE_MAX_LENGTH) || !isStringOfLength(message, 1, AGENT_MESSAGE_MAX_LENGTH)) {
    return resultInvalid("failの形式が不正です");
  }
  return { ok: true, query: { code, message } };
}
