// Pure planners for the GUI's checkout/add/delete/revert toolbar verbs
// (Task 3, charter sub-task 18c). No React, no I/O: each function takes the
// engine's raw per-path statuses (`rawStatusByPath`, a Map path -> status
// string built from `engine.statuses` — see useFileControl.js) plus whatever
// server/local file maps it needs, and returns the plain data the hook uses
// to build a cambium-grammar command line (via fileCommand.buildCommandLine)
// or to decide whether a confirm dialog is needed.
//
// The status strings match statusChips.js's `case` labels, which mirror
// cambium-core's `FileStatus` enum (crates/cambium-core/src/status.rs) as
// serialized by the wasm/native compute_status bridge: 'notAdded',
// 'markedForAdd', 'markedForDelete', 'checkedOutByMe', 'modified',
// 'outdated', 'lockedByOther', 'lockedByMeElsewhere', 'namingIssue'.
import { buildCommandLine } from './fileCommand';

function isBlockingLock(status) {
  return status === 'lockedByOther' || status === 'lockedByMeElsewhere';
}

/**
 * Classifies the local-only files under the current selection (`scopePaths`
 * — every path present in `localFiles` but absent from `serverFiles`, per
 * the caller's scope filter) for the Add New button's add-mode.
 *
 * `add` = the subset whose raw status is `'notAdded'` (the executor's `add`
 * verb target set). `ignoredOnly` is true when the scope had local-only
 * files but none of them are addable AND none are already marked for add —
 * the only way that happens is every one of them is on the ignore list (the
 * status engine reports no status at all for an ignored local-only file, so
 * it can't be 'notAdded' or 'markedForAdd'), which is what drives the
 * existing "these files are on the ignore list" info dialog.
 */
export function classifyAddTargets(scopePaths, rawStatusByPath) {
  const add = scopePaths.filter((path) => rawStatusByPath.get(path) === 'notAdded');
  const markedCount = scopePaths.filter((path) => rawStatusByPath.get(path) === 'markedForAdd').length;
  const ignoredOnly = scopePaths.length > 0 && add.length === 0 && markedCount === 0;
  return { add, ignoredOnly };
}

/**
 * Splits the delete button's selection into what the executor's `delete`
 * verb can handle (server-backed content) and what has to stay on the
 * direct `deleteWorkspaceFiles` path (content with no server copy behind
 * it — the executor refuses a never-added FILE target non-interactively,
 * per its own doc comment).
 *
 * `scopePaths` is every file path (server-backed or local-only) under the
 * selection; `scopeFolders` is every folder explicitly selected.
 *
 * A file already marked for delete, or locked by someone else (or by me on
 * another device), is left out of `engineFiles` — same gating `candidates.del`
 * applied before this task. A folder is `engineFolders` when anything
 * server-backed lives under it and nothing under it is locked by someone
 * else; otherwise, if it has no server-backed content at all, it's
 * `localOnlyFolders` (a purely local directory — deleted straight off disk,
 * nothing to submit). A folder whose server content is blocked by another
 * user's lock lands in neither list (not deletable right now, same as the
 * pre-task behavior of dropping it from `delFolders` entirely).
 *
 * `needsConfirm` mirrors the existing "Are you sure?" rule: true when any
 * unrecoverable (never-added) content is involved — a standalone local-only
 * file, or a local-only folder that actually has files under it. An empty
 * local-only folder has nothing to lose, so it doesn't force the warning.
 */
export function classifyDeleteTargets(scopePaths, scopeFolders, serverFiles, localFiles, rawStatusByPath) {
  const engineFiles = [];
  const localOnlyFiles = [];
  for (const path of scopePaths) {
    if (serverFiles.has(path)) {
      const status = rawStatusByPath.get(path);
      if (status !== 'markedForDelete' && !isBlockingLock(status)) engineFiles.push(path);
    } else if (localFiles.has(path)) {
      localOnlyFiles.push(path);
    }
  }

  const engineFolders = [];
  const localOnlyFolders = [];
  let localOnlyFolderHasFiles = false;
  for (const folder of scopeFolders) {
    const prefix = `${folder}/`;
    let hasServer = false;
    let blocked = false;
    for (const path of serverFiles.keys()) {
      if (path !== folder && !path.startsWith(prefix)) continue;
      hasServer = true;
      if (isBlockingLock(rawStatusByPath.get(path))) blocked = true;
    }
    if (hasServer) {
      if (!blocked) engineFolders.push(folder);
      continue;
    }
    localOnlyFolders.push(folder);
    for (const path of localFiles.keys()) {
      if (path.startsWith(prefix)) {
        localOnlyFolderHasFiles = true;
        break;
      }
    }
  }

  const needsConfirm = localOnlyFiles.length > 0 || localOnlyFolderHasFiles;
  return { engineFiles, engineFolders, localOnlyFiles, localOnlyFolders, needsConfirm };
}

/**
 * Builds the `revert` command line for `paths` and decides whether the
 * danger confirm dialog is needed first. `isModifiedFn(path)` reports
 * whether `path` carries real local changes that reverting would discard
 * (the same hash-diff check `doRevert` used before this task). The
 * returned `line` never carries `-f` — the caller appends `' -f'` itself
 * once the user confirms, since un-marking an add never needs it but
 * restoring a modified/deleted/checked-out file does (the executor's own
 * `-f` gate, mirrored here so the GUI can ask first instead of getting
 * refused).
 */
export function planRevert(paths, isModifiedFn) {
  const needsConfirm = paths.some((path) => isModifiedFn(path));
  const line = buildCommandLine('revert', paths);
  return { line, needsConfirm };
}

/**
 * Builds the `submit` command line (Task 4, charter sub-task 18d): the
 * changelist entries being submitted (`items[].path` — adds/edits/deletes)
 * plus any marked folder deletes, `-m <description>` naming the changelist
 * message. Every pre-check `performSubmit` used to run client-side (naming,
 * staleness) and the upload/commit orchestration now live in the executor's
 * `submit` verb — this just builds the line for `dispatchFileCommand`.
 */
export function planSubmit(items, folders, description) {
  const paths = [...items.map((item) => item.path), ...folders];
  return { line: buildCommandLine('submit', paths, { message: description }) };
}

/**
 * Builds the `pull` command line from a selection scope (the same
 * `{hasRoot, files, folders}` shape `selectionScopes` builds in
 * useFileControl.js). A root-scope selection pulls the whole workspace — a
 * bare `pull` with no paths, which the executor's own target resolution
 * treats as "everything" — regardless of what `files`/`folders` happen to
 * contain. Otherwise the selected files and folders become the pull's
 * depot-path targets directly: the executor gathers a fresh depot tree and
 * applies the locked-by-me / delete-marked / ignore-list exclusions itself,
 * so this no longer needs to pre-expand the selection into every underlying
 * server file the way `doPull` used to.
 */
export function planPull(scopes) {
  const paths = scopes.hasRoot ? [] : [...scopes.files, ...scopes.folders];
  return { line: buildCommandLine('pull', paths) };
}

/**
 * Seeds `pullingPaths` (the "not added" flicker suppression set — see
 * useFileControl.js's `syncPaths`/`doPull`) with the files a pull is about
 * to bring onto disk for the FIRST time: server paths under `scopePaths`
 * (the same file/folder targets `planPull` builds a command line from —
 * `scopePaths.length === 0` means a root-scope pull, i.e. the whole
 * workspace, same convention as `planPull`) that are not already present in
 * `localFiles`.
 *
 * Progress events fill this set incrementally as files actually land, but at
 * pull start it used to be empty — so a file that lands on disk before its
 * first progress event could flash a 'notAdded' chip before the suppression
 * caught up. Seeding up front closes that window.
 *
 * A path already in `localFiles` is deliberately left out even if it's also
 * server-backed and in scope: it's already on disk, so there's no
 * appear-then-flicker risk, and leaving it unsuppressed is what lets a
 * genuinely local-only sibling still show its real `notAdded` chip mid-pull
 * (that file was never a member of this set to begin with, since it isn't a
 * server path).
 *
 * Pure — no React, no I/O.
 */
export function seedPullingPaths(scopePaths, serverFiles, localFiles) {
  const whole = !scopePaths || scopePaths.length === 0;
  const seed = new Set();
  for (const path of serverFiles.keys()) {
    if (localFiles.has(path)) continue;
    if (whole || scopePaths.some((scope) => path === scope || path.startsWith(`${scope}/`))) {
      seed.add(path);
    }
  }
  return seed;
}

const NAMING_ISSUES_TEXT =
  'These names break a file-naming rule. Different operating systems ' +
  '(Windows, macOS, Linux) allow different characters and forms, and ' +
  'each of these would fail on at least one of them — so the server ' +
  'will not store them. Rename the files to fix the issue below and ' +
  'submit again. They stay in your changelist until then.';

const NOT_SUBMITTED_TEXT =
  'These files were changed by someone else or are checked out, so they ' +
  'were not submitted and remain in your changelist. Back up any important ' +
  'changes, revert the files to get the new versions, then re-apply your ' +
  'changes after checking them out.';

const UPLOAD_FAILED_TEXT = 'These files could not be uploaded to the server:';

/**
 * Maps a submit's `dispatchFileCommand` result to the dialog `performSubmit`
 * should show, or `null` for a clean success.
 *
 * On success (`result.ok`), splits `result.data.conflicts` the way the
 * pre-Task-4 client-side code did: a `reason` starting with `'unsafe name:'`
 * is a naming rejection — its own warning-styled dialog, and it takes
 * priority (shown alone) over any other conflict in the same response. Every
 * other conflict is a lock/staleness rejection; its holder is resolved
 * through `locks` (the file-control lock table) and `getUser` (display-name
 * lookup) the same way the rest of the app resolves lock holders, falling
 * back to the conflict's own `reason` when the path carries no lock.
 *
 * On failure (`!result.ok`), a `result.data.failures` payload (the
 * executor's additive per-file upload-failure rows, Task 18d) gets its own
 * red filelist dialog; anything else — a naming/stale pre-check refusal, a
 * rejected submit, a session/network error — falls back to a plain dialog
 * showing the executor's own human message.
 *
 * Pure — no React, no I/O.
 */
export function submitDialogs(result, locks, getUser) {
  if (result.ok) {
    const conflicts = result.data?.conflicts ?? [];
    if (!conflicts.length) return null;

    const naming = conflicts.filter(
      (c) => typeof c.reason === 'string' && c.reason.startsWith('unsafe name:')
    );
    if (naming.length) {
      return {
        type: 'filelist',
        variant: 'warning',
        icon: 'assets/circle_exclaim.svg',
        color: 'orange',
        title: 'Naming Issues',
        text: NAMING_ISSUES_TEXT,
        rows: naming.map((c) => ({ path: c.path, note: c.reason.replace(/^unsafe name:\s*/, '') })),
        columnLabel: 'Naming rule',
      };
    }

    const rows = conflicts.map((c) => {
      const lock = locks[c.path];
      const holder = lock ? getUser(lock.username)?.displayName ?? lock.username : null;
      return { path: c.path, note: holder ?? c.reason };
    });
    return {
      type: 'filelist',
      variant: 'danger',
      icon: 'assets/circle_exclaim.svg',
      color: 'grey',
      title: 'Some files were not submitted',
      text: NOT_SUBMITTED_TEXT,
      rows,
      columnLabel: 'Checked out by',
    };
  }

  const failures = result.data?.failures;
  if (failures?.length) {
    return {
      type: 'filelist',
      variant: 'danger',
      icon: 'assets/circle_exclaim.svg',
      color: 'red',
      title: 'Submit failed',
      text: UPLOAD_FAILED_TEXT,
      rows: failures.map((f) => ({ path: f.path, note: f.error })),
      columnLabel: 'Problem',
    };
  }
  return {
    type: 'info',
    icon: 'assets/circle_exclaim.svg',
    color: 'orange',
    title: 'Submit failed',
    text: result.human,
  };
}
