/**
 * 레거시 목적지 이관 CLI (M5 Task 3(c)). **기본은 dry-run** 이며 `--apply` 에서만 쓴다.
 * 계획 함수는 `apps/api/src/modules/alert/legacy-destination.ts` 에 있고 여기서는 DB 읽기·쓰기만 한다.
 *
 * 사용 (비밀번호는 셸 기록에 남지 않게 입력으로 받는다):
 *   read -rs MYSQL_PWD   # export 하지 않는다 — 아래 명령에만 넘긴다
 *   MYSQL_PWD="$MYSQL_PWD" ALERT_WEBHOOK_HOST_ALLOW='...' npx ts-node -r tsconfig-paths/register \
 *     scripts/legacy-destination-migrate.ts --host H --port P --user U --database D
 *   MYSQL_PWD="$MYSQL_PWD" ALERT_WEBHOOK_HOST_ALLOW='...' ... --host H --port P --user U --database D \
 *     --apply --target H:P/D --expect <dry-run 이 낸 승인 해시>
 *
 * 비밀번호는 `MYSQL_PWD` 환경 변수로만 받는다. 호스트·포트·DB 는 기본값 없이 명시한다.
 * 오류 출력은 코드와 errno 와 고정 문구만 낸다 (URL 이 든 sql · sqlMessage · message 를 찍지 않는다).
 */
import mysql from 'mysql2/promise';
import { isAllowedWebhookUrl, parseAllowSuffixEnv } from '../apps/api/src/modules/alert/alert-url';
import type { SelectableChannel } from '../apps/api/src/modules/alert/destination-select';
import {
  type MigrationInput,
  type MigrationPlan,
  hashHead,
  planLegacyMigration,
  renderPlan,
} from '../apps/api/src/modules/alert/legacy-destination';

/** 사용자에게 보이는 고정 문구만 담는 오류. 외부 원문을 싣지 않는다. */
export class CliRefusal extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'CliRefusal';
  }
}

export interface CliArgs {
  host: string;
  port: number;
  user: string;
  database: string;
  apply: boolean;
  target: string | null;
  expect: string | null;
}

export function parseArgs(argv: string[]): CliArgs {
  const opt = (name: string): string | null => {
    const i = argv.indexOf(`--${name}`);
    if (i < 0) return null;
    const v = argv[i + 1];
    return v === undefined || v.startsWith('--') ? null : v;
  };
  const host = opt('host');
  const portRaw = opt('port');
  const user = opt('user');
  const database = opt('database');
  if (!host || !portRaw || !user || !database) {
    throw new CliRefusal('사용: --host --port --user --database 를 모두 명시한다 (기본값 없음)');
  }
  const port = Number(portRaw);
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new CliRefusal('--port 값이 올바르지 않다');
  const apply = argv.includes('--apply');
  const target = opt('target');
  const expect = opt('expect');
  if (apply && (!target || !expect)) {
    throw new CliRefusal('--apply 는 --target 과 --expect 가 모두 필요하다');
  }
  return { host, port, user, database, apply, target, expect };
}

/**
 * `--apply` 의 사전 거부 판정. 보존 실패가 있으면 승인으로 풀리지 않는다.
 * 승인 해시가 다르면 아무것도 쓰지 않는다.
 */
export function applyRefusal(plan: Pick<MigrationPlan, 'preservation' | 'digest'>, expect: string | null): string | null {
  if (expect === null) return '--expect 가 없다 (승인 해시가 필요하다)';
  if (plan.preservation > 0) {
    return `보존 실패 ${plan.preservation}건 — 승인으로 풀리지 않는다. 기존 채널 구성을 고친 뒤 다시 계획한다`;
  }
  if (plan.digest !== expect) return '승인 해시가 현재 계획과 다르다 — 아무것도 쓰지 않는다';
  return null;
}

const safeToken = (v: unknown): string =>
  (typeof v === 'string' || typeof v === 'number') && /^[A-Za-z0-9_]{1,40}$/.test(String(v)) ? String(v) : '없음';

/** 오류를 사람이 읽을 한 줄로 바꾼다. 원문 (sql · sqlMessage · message · stack) 은 어떤 경우에도 싣지 않는다. */
export function describeError(e: unknown): string {
  if (e instanceof CliRefusal) return e.message;
  const code = safeToken((e as { code?: unknown } | null)?.code);
  const errno = safeToken((e as { errno?: unknown } | null)?.errno);
  return `DB 오류 (code=${code}, errno=${errno}). 원문은 출력하지 않는다`;
}

/** 쓰기 트랜잭션 안에서 다시 읽는 채널 행. 첫 읽기는 잠금 읽기다 (`FOR UPDATE`). */
async function readChannels(conn: mysql.Connection, lock: boolean): Promise<SelectableChannel[]> {
  const [rows] = await conn.query(
    `SELECT alc_id AS alcId, alc_name AS alcName, alc_url AS alcUrl, alc_types AS alcTypes, alc_enabled AS alcEnabled
       FROM tn_alert_channel ORDER BY alc_id${lock ? ' FOR UPDATE' : ''}`,
  );
  return (rows as Array<Record<string, unknown>>).map((r) => ({
    alcId: Number(r.alcId),
    alcName: String(r.alcName ?? ''),
    alcUrl: String(r.alcUrl ?? ''),
    alcTypes: r.alcTypes === null || r.alcTypes === undefined ? null : String(r.alcTypes),
    alcEnabled: Number(r.alcEnabled),
  }));
}

/** 옛 목적지 두 원천. 런타임 `readWebhookConfig` 와 `list()` 의 규칙을 그대로 따른다. */
async function readLegacy(conn: mysql.Connection, fullKinds: string[]): Promise<Pick<MigrationInput, 'global' | 'vendors'>> {
  const [settingRows] = await conn.query(
    `SELECT tst_key AS k, tst_value AS v FROM tn_treat_setting WHERE tst_key IN ('monitor_webhook_url', 'monitor_webhook_types')`,
  );
  const settings = new Map((settingRows as Array<{ k: string; v: string }>).map((r) => [r.k, r.v]));
  const url = String(settings.get('monitor_webhook_url') ?? '');
  const raw = settings.get('monitor_webhook_types');
  let types = fullKinds.slice();
  if (raw) {
    try {
      const p: unknown = JSON.parse(raw);
      if (Array.isArray(p)) types = p.filter((x): x is string => typeof x === 'string');
    } catch {
      /* 깨진 JSON 은 기본 종류 (런타임과 같다) */
    }
  }
  const [vendorRows] = await conn.query(`SELECT tpr_key AS tprKey, tpr_webhook_url AS tprWebhookUrl FROM tn_treat_provider`);
  return {
    global: { url, types },
    vendors: vendorRows as Array<{ tprKey: string; tprWebhookUrl: string | null }>,
  };
}

async function main(): Promise<number> {
  let args: CliArgs;
  try {
    args = parseArgs(process.argv.slice(2));
  } catch (e) {
    console.error(describeError(e));
    return 2;
  }
  const password = process.env.MYSQL_PWD;
  if (!password) {
    console.error('거부: MYSQL_PWD 미설정 (비밀번호는 환경 변수로만 받는다).');
    return 2;
  }

  //* 지연 require: import 만으로는 Nest 와 설정이 올라오지 않는다. 종류 집합의 사본을 두지 않는다 (INV4).
  /* eslint-disable @typescript-eslint/no-require-imports */
  const { ALERT_TYPES } = require('../apps/api/src/modules/treat/provider/treat-provider.service');
  /* eslint-enable @typescript-eslint/no-require-imports */
  const fullKinds: string[] = (ALERT_TYPES as Array<{ key: string }>).map((t) => t.key);

  //* 허용 술어는 런타임과 같은 환경 변수에서 만든다 (`alert.webhookHostAllow`, 기본은 구글챗·슬랙).
  const allowed = parseAllowSuffixEnv(process.env.ALERT_WEBHOOK_HOST_ALLOW).allowed;
  const isAllowed = (u: string) => isAllowedWebhookUrl(u, allowed);
  console.log(`허용 접미사 해시: ${hashHead(allowed.join(','), 12)} (ALERT_WEBHOOK_HOST_ALLOW 기준)`);

  let conn: mysql.Connection | undefined;
  try {
    conn = await mysql.createConnection({
      host: args.host,
      port: args.port,
      user: args.user,
      password,
      database: args.database,
    });
    const [infoRows] = await conn.query('SELECT @@hostname AS hostname, DATABASE() AS db, @@innodb_read_only AS ro');
    const info = (infoRows as Array<{ hostname: string; db: string; ro: number }>)[0];
    const readOnly = Number(info.ro) === 1;
    console.log(`접속: host=${info.hostname} db=${info.db} innodb_read_only=${readOnly ? 1 : 0}`);

    if (!args.apply) {
      await conn.query('START TRANSACTION READ ONLY');
      const legacy = await readLegacy(conn, fullKinds);
      const channels = await readChannels(conn, false);
      const plan = planLegacyMigration({ ...legacy, channels, fullKinds, isAllowed });
      await conn.query('COMMIT');
      console.log(renderPlan(plan));
      console.log('\n(dry-run: 아무것도 쓰지 않았다)');
      return 0;
    }

    if (readOnly) throw new CliRefusal('--apply 거부: 이 서버는 innodb_read_only=1 이다');
    if (args.target !== `${args.host}:${args.port}/${info.db}`) {
      throw new CliRefusal('--apply 거부: --target 이 실제 접속 대상과 다르다. 아무것도 쓰지 않았다');
    }

    await conn.query('START TRANSACTION');
    try {
      //* 잠금을 먼저 잡고 계획을 다시 만든다. 같은 트랜잭션 재읽기는 다른 세션의 커밋을 보지 못하므로,
      //*  직렬화는 이 잠금이 맡는다 (잠금 읽기가 끝날 때까지 다른 쓰기는 기다린다).
      const channels = await readChannels(conn, true);
      const legacy = await readLegacy(conn, fullKinds);
      const input: MigrationInput = { ...legacy, channels, fullKinds, isAllowed };
      const plan = planLegacyMigration(input);
      const refusal = applyRefusal(plan, args.expect);
      if (refusal) throw new CliRefusal(`--apply 거부: ${refusal}`);

      for (const r of plan.rows) {
        if (r.action !== 'create') continue;
        await conn.execute('INSERT INTO tn_alert_channel (alc_name, alc_url, alc_types, alc_enabled) VALUES (?, ?, ?, 1)', [
          r.name,
          r.url,
          JSON.stringify(r.types),
        ]);
      }

      //* 쓴 뒤 같은 트랜잭션 안에서 다시 만든다. 새 행은 재사용 대상이라 새 행이 남지 않아야 하고,
      //*  보존 실패가 0 이며 승인 해시가 그대로여야 한다. 하나라도 어긋나면 던져서 전부 되돌린다.
      const after = planLegacyMigration({ ...input, channels: await readChannels(conn, false) });
      if (after.preservation > 0 || after.createCount > 0 || after.digest !== args.expect) {
        throw new CliRefusal('쓴 뒤 재확인이 승인 계획과 어긋났다 — 전부 되돌렸다');
      }
      await conn.query('COMMIT');
      console.log(`이관 완료: 새 행 ${plan.createCount}개 · 승인 해시 ${after.digest}`);
      return 0;
    } catch (e) {
      await conn.query('ROLLBACK').catch(() => undefined);
      throw e;
    }
  } catch (e) {
    console.error(describeError(e));
    return 1;
  } finally {
    await conn?.end().catch(() => undefined);
  }
}

//* import 만으로는 아무것도 실행하지 않는다 (spec 이 순수 함수를 가져간다).
if (require.main === module) {
  main().then((code) => {
    process.exitCode = code;
  });
}
