/**
 * ID mapping management for short public IDs.
 *
 * Maps 4-char base36 short IDs to 26-char ULIDs.
 * Stored in .tbd/data-sync/mappings/ids.yml
 *
 * See: tbd-design.md §2.5 ID Generation
 */

import { readFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { writeFile } from 'atomically';

import {
  parseYamlToleratingDuplicateKeys,
  stringifyYaml,
  hasMergeConflictMarkers,
} from '../utils/yaml-utils.js';
import { withLockfile } from '../utils/lockfile.js';

import {
  generateShortId,
  extractUlidFromInternalId,
  makeInternalId,
  isInternalId,
  extractShortId,
  asInternalId,
  type InternalIssueId,
} from '../lib/ids.js';
import { naturalSort } from '../lib/sort.js';
import { IdMappingYamlSchema } from '../lib/schemas.js';

/**
 * ID mapping from short ID to ULID.
 * Format in ids.yml:
 *   a7k2: 01hx5zzkbkactav9wevgemmvrz
 *   b3m9: 01hx5zzkbkbctav9wevgemmvrz
 */
export interface IdMapping {
  shortToUlid: Map<string, string>;
  ulidToShort: Map<string, string>;
}

/**
 * Get the path to the ids.yml mapping file.
 */
function getMappingPath(baseDir: string): string {
  return join(baseDir, 'mappings', 'ids.yml');
}

/**
 * Load the ID mapping from disk.
 * Returns empty mapping if file doesn't exist.
 */
export async function loadIdMapping(baseDir: string): Promise<IdMapping> {
  const filePath = getMappingPath(baseDir);

  let content: string;
  try {
    content = await readFile(filePath, 'utf-8');
  } catch {
    // File doesn't exist - return empty mapping
    return {
      shortToUlid: new Map(),
      ulidToShort: new Map(),
    };
  }

  // Parse tolerating duplicate keys — this handles the case where a git merge
  // conflict resolution kept entries from both sides, creating duplicate YAML keys.
  // Without this, the yaml parser throws "Map keys must be unique".
  const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(
    content,
    filePath,
  );
  const data = rawData ?? {};

  if (duplicateKeys.length > 0) {
    console.warn(
      `Warning: ${filePath} contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +
        `This usually happens after a git merge conflict resolution. ` +
        `The file will be auto-fixed on next save.`,
    );
  }

  // Validate with Zod schema - ensures all keys are valid short IDs and values are ULIDs
  const parseResult = IdMappingYamlSchema.safeParse(data);
  if (!parseResult.success) {
    throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);
  }
  const validData = parseResult.data;

  const shortToUlid = new Map<string, string>();
  const ulidToShort = new Map<string, string>();

  for (const [shortId, ulid] of Object.entries(validData)) {
    shortToUlid.set(shortId, ulid);
    ulidToShort.set(ulid, shortId);
  }

  return { shortToUlid, ulidToShort };
}

/**
 * Save the ID mapping to disk with mutual exclusion.
 *
 * Uses a lockfile to serialize concurrent writers, then performs read-merge-write
 * inside the lock. This prevents the lost-update problem when multiple `tbd create`
 * commands run in parallel.
 *
 * The merge is safe because ID mappings are append-only — entries are never
 * intentionally removed. If the lock cannot be acquired within the timeout,
 * a LockAcquisitionError is thrown rather than proceeding without protection.
 */
export async function saveIdMapping(baseDir: string, mapping: IdMapping): Promise<void> {
  const filePath = getMappingPath(baseDir);

  // Ensure directory exists
  await mkdir(dirname(filePath), { recursive: true });

  await withLockfile(filePath + '.lock', async () => {
    // Inside the lock: read current on-disk state, merge with our in-memory
    // mapping, and write the result. Our entries take precedence for short ID
    // conflicts (extremely unlikely with random 4-char base36 IDs).
    let merged = mapping;
    let onDiskSize = 0;
    try {
      const onDisk = await loadIdMappingRaw(filePath);
      onDiskSize = onDisk.shortToUlid.size;
      if (onDiskSize > 0) {
        merged = mergeIdMappings(mapping, onDisk);
      }
    } catch {
      // File doesn't exist or is unreadable — proceed with our mapping only
    }

    // Safety check: ID mappings are append-only. If the merged result has fewer
    // entries than what's on disk, something went wrong. Refuse to write so the
    // caller can investigate rather than silently destroying entries.
    if (merged.shortToUlid.size < onDiskSize) {
      throw new Error(
        `Refusing to save ID mapping: would lose ${onDiskSize - merged.shortToUlid.size} entries ` +
          `(on-disk: ${onDiskSize}, proposed: ${merged.shortToUlid.size}). ` +
          `ID mappings are append-only — this indicates a bug.`,
      );
    }

    const data: Record<string, string> = {};
    const sortedKeys = naturalSort(Array.from(merged.shortToUlid.keys()));
    for (const key of sortedKeys) {
      data[key] = merged.shortToUlid.get(key)!;
    }

    const content = stringifyYaml(data);
    await writeFile(filePath, content);
  });
}

/**
 * Load an ID mapping directly from a file path (internal helper for save merging).
 * Separated from loadIdMapping to avoid coupling the save path to baseDir resolution.
 */
async function loadIdMappingRaw(filePath: string): Promise<IdMapping> {
  const content = await readFile(filePath, 'utf-8');

  const { data: rawData } = parseYamlToleratingDuplicateKeys<unknown>(content, filePath);
  const data = rawData ?? {};

  const parseResult = IdMappingYamlSchema.safeParse(data);
  if (!parseResult.success) {
    throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);
  }
  const validData = parseResult.data;

  const shortToUlid = new Map<string, string>();
  const ulidToShort = new Map<string, string>();

  for (const [shortId, ulid] of Object.entries(validData)) {
    shortToUlid.set(shortId, ulid);
    ulidToShort.set(ulid, shortId);
  }

  return { shortToUlid, ulidToShort };
}

/**
 * Calculate the optimal short ID length based on existing ID count.
 *
 * At 50K issues, switches from 4-char to 5-char IDs to keep
 * collision probability low (~3% per attempt with 4 chars at 50K).
 *
 * With 10 retries per length, actual failure probability is astronomically low.
 */
export function calculateOptimalLength(existingCount: number): number {
  return existingCount < 50_000 ? 4 : 5;
}

/**
 * Generate a unique short ID that doesn't collide with existing ones.
 *
 * Calculates optimal length (4 or 5 chars) based on existing ID count,
 * then retries with the next length if collisions occur.
 *
 * @returns The new short ID
 * @throws If unable to generate a unique ID after max attempts
 */
export function generateUniqueShortId(mapping: IdMapping): string {
  const ATTEMPTS_PER_LENGTH = 10;
  const existingCount = mapping.shortToUlid.size;
  const optimalLength = calculateOptimalLength(existingCount);

  // Try optimal length first, then fall back to longer if needed
  for (const length of [optimalLength, optimalLength + 1]) {
    for (let attempt = 0; attempt < ATTEMPTS_PER_LENGTH; attempt++) {
      const shortId = generateShortId(length);
      if (!mapping.shortToUlid.has(shortId)) {
        return shortId;
      }
    }
  }

  throw new Error(
    `Failed to generate unique short ID after 20 attempts with ${existingCount} existing IDs. ` +
      `This should be extremely rare - please report if you see this error.`,
  );
}

/**
 * Register a new ID mapping.
 * @param ulid - The ULID (without is- prefix)
 * @param shortId - The short ID (4 chars)
 */
export function addIdMapping(mapping: IdMapping, ulid: string, shortId: string): void {
  mapping.shortToUlid.set(shortId, ulid);
  mapping.ulidToShort.set(ulid, shortId);
}

/**
 * Get the short ID for a ULID.
 * @param ulid - The ULID (without is- prefix)
 * @returns The short ID, or undefined if not found
 */
export function getShortId(mapping: IdMapping, ulid: string): string | undefined {
  return mapping.ulidToShort.get(ulid);
}

/**
 * Get the ULID for a short ID.
 * @param shortId - The short ID
 * @returns The ULID (without is- prefix), or undefined if not found
 */
export function getUlid(mapping: IdMapping, shortId: string): string | undefined {
  return mapping.shortToUlid.get(shortId);
}

/**
 * Check if a short ID exists in the mapping.
 */
export function hasShortId(mapping: IdMapping, shortId: string): boolean {
  return mapping.shortToUlid.has(shortId);
}

/**
 * Create a short ID mapping for a new internal ID.
 * Generates a unique short ID and registers it in the mapping.
 *
 * @param internalId - The internal ID (is-{ulid})
 * @param mapping - The ID mapping to update
 * @returns The generated short ID
 */
export function createShortIdMapping(internalId: string, mapping: IdMapping): string {
  // Extract ULID from internal ID (remove prefix)
  const ulid = extractUlidFromInternalId(internalId);

  // Check if already mapped
  const existing = mapping.ulidToShort.get(ulid);
  if (existing) {
    return existing;
  }

  // Generate unique short ID
  const shortId = generateUniqueShortId(mapping);

  // Register mapping
  addIdMapping(mapping, ulid, shortId);

  return shortId;
}

/**
 * Resolve any ID input to an internal ID ({prefix}-{ulid}).
 *
 * Handles:
 * - Internal IDs: {prefix}-{ulid} -> {prefix}-{ulid}
 * - Short IDs: a7k2 -> {prefix}-{ulid from mapping}
 * - Prefixed short IDs: bd-a7k2 -> {prefix}-{ulid from mapping}
 *
 * @param input - The ID input (short ID, prefixed short ID, or internal ID)
 * @param mapping - The ID mapping for short ID resolution
 * @returns The internal ID ({prefix}-{ulid})
 * @throws If the short ID is not found in the mapping
 */
export function resolveToInternalId(input: string, mapping: IdMapping): InternalIssueId {
  const lower = input.toLowerCase();

  // If it's already an internal ID, return it
  if (isInternalId(lower)) {
    return asInternalId(lower);
  }

  // Extract the short ID portion (strips any prefix like "bd-" or "is-")
  const shortId = extractShortId(lower);

  // If it's a full ULID (26 chars), it might be a bare internal ID
  if (shortId.length === 26 && /^[0-9a-z]{26}$/.test(shortId)) {
    return makeInternalId(shortId);
  }

  // Must be a short ID - look it up in the mapping
  const ulid = mapping.shortToUlid.get(shortId);
  if (!ulid) {
    throw new Error(`Unknown issue ID: ${input}. ` + `Short ID "${shortId}" not found in mapping.`);
  }

  return makeInternalId(ulid);
}

/**
 * Parse an ID mapping from raw YAML content.
 * Used for loading mappings from git show output during conflict resolution.
 *
 * @throws MergeConflictError if content contains merge conflict markers
 */
export function parseIdMappingFromYaml(content: string): IdMapping {
  // Parse tolerating duplicate keys — handles post-merge-conflict duplicates
  const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(content);
  const data = rawData ?? {};

  if (duplicateKeys.length > 0) {
    console.warn(
      `Warning: ID mapping YAML contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +
        `Duplicates will be auto-resolved.`,
    );
  }

  // Validate with Zod schema
  const parseResult = IdMappingYamlSchema.safeParse(data);
  if (!parseResult.success) {
    throw new Error(`Invalid ID mapping format: ${parseResult.error.message}`);
  }
  const validData = parseResult.data;

  const shortToUlid = new Map<string, string>();
  const ulidToShort = new Map<string, string>();

  for (const [shortId, ulid] of Object.entries(validData)) {
    shortToUlid.set(shortId, ulid);
    ulidToShort.set(ulid, shortId);
  }

  return { shortToUlid, ulidToShort };
}

/**
 * Ensure all given internal IDs have short ID mappings.
 * Creates missing mappings for any IDs without entries.
 *
 * This repairs state after git merges that may add issue files
 * without corresponding mapping entries (e.g., when outbox issues
 * are merged from a feature branch but ids.yml doesn't include them).
 *
 * When a `historicalMapping` is provided, the function will try to recover
 * the original short ID from that mapping before generating a new random one.
 * This preserves ID stability so that existing references (in docs, PRs,
 * conversations) remain valid.
 *
 * @param internalIds - Array of internal IDs (is-{ulid}) to reconcile
 * @param mapping - The ID mapping to update (mutated in-place)
 * @param historicalMapping - Optional mapping from prior state (e.g., git history) to recover original short IDs
 * @returns Object with `created` (IDs that got new random short IDs) and `recovered` (IDs restored from history)
 */
export function reconcileMappings(
  internalIds: string[],
  mapping: IdMapping,
  historicalMapping?: IdMapping,
): { created: string[]; recovered: string[] } {
  const created: string[] = [];
  const recovered: string[] = [];

  for (const id of internalIds) {
    const ulid = extractUlidFromInternalId(id);
    if (mapping.ulidToShort.has(ulid)) {
      continue; // Already has a mapping
    }

    // Try to recover original short ID from historical mapping
    const historicalShortId = historicalMapping?.ulidToShort.get(ulid);
    if (historicalShortId && !mapping.shortToUlid.has(historicalShortId)) {
      // Recovered: restore the original short ID
      addIdMapping(mapping, ulid, historicalShortId);
      recovered.push(id);
    } else {
      // No history available or short ID conflicts — generate new random one
      createShortIdMapping(id, mapping);
      created.push(id);
    }
  }

  return { created, recovered };
}

/**
 * Merge two ID mappings by combining all entries from both.
 * ID mappings are always additive (new IDs are only added, never removed),
 * so merging simply unions all key-value pairs.
 *
 * If the same short ID maps to different ULIDs in each mapping (a conflict),
 * the local mapping takes precedence (caller should log a warning).
 *
 * @param local - The local ID mapping
 * @param remote - The remote ID mapping
 * @returns Merged mapping with all entries from both
 */
export function mergeIdMappings(local: IdMapping, remote: IdMapping): IdMapping {
  const merged: IdMapping = {
    shortToUlid: new Map(local.shortToUlid),
    ulidToShort: new Map(local.ulidToShort),
  };

  // Add all remote entries that don't conflict
  for (const [shortId, ulid] of remote.shortToUlid) {
    if (!merged.shortToUlid.has(shortId)) {
      merged.shortToUlid.set(shortId, ulid);
      merged.ulidToShort.set(ulid, shortId);
    }
    // If shortId already exists with different ulid, keep local (conflict resolution)
  }

  // Also check for ULIDs that exist in remote but not in local
  // (different short ID for same ULID - shouldn't happen but handle gracefully)
  for (const [ulid, shortId] of remote.ulidToShort) {
    if (!merged.ulidToShort.has(ulid) && !merged.shortToUlid.has(shortId)) {
      merged.shortToUlid.set(shortId, ulid);
      merged.ulidToShort.set(ulid, shortId);
    }
  }

  return merged;
}

/**
 * Resolve merge conflicts in ids.yml content by extracting both sides and merging.
 *
 * ids.yml is a sorted key-value YAML map where entries are append-only.
 * The most common merge conflict is both sides adding non-overlapping keys,
 * which is trivially auto-resolvable by keeping all entries from both sides.
 *
 * @param content - Raw file content that may contain git merge conflict markers
 * @returns Merged IdMapping with entries from both sides
 */
export function resolveIdMappingConflicts(content: string): IdMapping {
  if (!hasMergeConflictMarkers(content)) {
    return parseIdMappingFromYaml(content);
  }

  const lines = content.split('\n');
  const oursLines: string[] = [];
  const theirsLines: string[] = [];
  let inConflict: 'none' | 'ours' | 'theirs' = 'none';

  for (const line of lines) {
    if (line.startsWith('<<<<<<< ')) {
      inConflict = 'ours';
      continue;
    }
    if (line === '=======' && inConflict === 'ours') {
      inConflict = 'theirs';
      continue;
    }
    if (line.startsWith('>>>>>>> ') && inConflict === 'theirs') {
      inConflict = 'none';
      continue;
    }

    if (inConflict === 'none') {
      oursLines.push(line);
      theirsLines.push(line);
    } else if (inConflict === 'ours') {
      oursLines.push(line);
    } else {
      theirsLines.push(line);
    }
  }

  const oursMapping = parseIdMappingFromYaml(oursLines.join('\n'));
  const theirsMapping = parseIdMappingFromYaml(theirsLines.join('\n'));

  return mergeIdMappings(oursMapping, theirsMapping);
}
