import { expectDefined } from '@wrongstack/core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { Tool, ToolProgressEvent, ToolStreamEvent } from '@wrongstack/core';
import { safeResolve } from './_util.js';
const DEFAULT_IGNORE = [
  'node_modules',
  '.git',
  'dist',
  'build',
  '.next',
  'coverage',
  '__pycache__',
  '.wrongstack',
  '.ssh',
  '.gnupg',
  '.aws',
];

interface TreeInput {
  path?: string | undefined;
  depth?: number | undefined;
  glob?: string | undefined;
  exclude?: string[] | undefined;
  show_files?: boolean | undefined;
  show_dirs?: boolean | undefined;
  show_hidden?: boolean | undefined;
}

interface TreeOutput {
  tree: string;
  total_files: number;
  total_dirs: number;
  truncated: boolean;
  path: string;
}

export const treeTool: Tool<TreeInput, TreeOutput> = {
  name: 'tree',
  category: 'Filesystem',
  description:
    'Display a directory tree of the project (or a subpath). This is the recommended way to explore the high-level structure of a codebase before reading specific files.',
  usageHint:
    'BEST PRACTICE FOR INITIAL EXPLORATION:\n\n' +
    '- Call early when working with an unfamiliar project or module.\n' +
    '- Tune `depth` (default 3) and use `glob`/`exclude` to focus the view.\n' +
    '- Prefer this over raw `bash find` or `glob` + manual reading when you need a quick structural overview.\n' +
    'Output is truncated for very large trees.',
  permission: 'auto',
  mutating: false,
  capabilities: ['fs.read'],
  timeoutMs: 15_000,
  inputSchema: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description: 'Root directory to display the tree from (defaults to project root).',
      },
      depth: {
        type: 'integer',
        description: 'Maximum directory depth to traverse (default 3, use 0 for unlimited).',
        minimum: 0,
        maximum: 20,
      },
      glob: {
        type: 'string',
        description: 'Only include files matching this glob pattern.',
      },
      exclude: {
        type: 'array',
        items: { type: 'string' },
        description: 'List of directory names to completely ignore.',
      },
      show_files: {
        type: 'boolean',
        description: 'Whether to show individual files (default true).',
      },
      show_dirs: {
        type: 'boolean',
        description: 'Whether to show directories (default true).',
      },
      show_hidden: {
        type: 'boolean',
        description: 'Show hidden files starting with . (default: false)',
      },
    },
  },
  async execute(input, ctx, opts) {
    let final: TreeOutput | undefined;
    const executeStream = treeTool.executeStream;
    if (!executeStream) throw new Error('treeTool: stream execution unavailable');
    for await (const ev of executeStream(input, ctx, opts)) {
      if (ev.type === 'final') final = ev.output;
    }
    if (!final) throw new Error('tree: stream ended without final event');
    return final;
  },
  async *executeStream(input, ctx): AsyncGenerator<ToolStreamEvent<TreeOutput>> {
    const basePath = input.path ? safeResolve(input.path, ctx) : ctx.cwd;
    const maxDepth = input.depth ?? 3;
    const showFiles = input.show_files ?? true;
    const showDirs = input.show_dirs ?? true;
    const showHidden = input.show_hidden ?? false;
    const exclude = new Set([...DEFAULT_IGNORE, ...(input.exclude ?? [])]);
    const filterGlob = input.glob;

    const lines: string[] = [basePath];
    const totals = { totalFiles: { value: 0 }, totalDirs: { value: 0 } };

    // Walker pushes progress into an async queue; the generator drains it.
    const queue: ToolProgressEvent[] = [];
    const FLUSH_EVERY = 200; // emit metric every 200 entries seen
    let lastEmittedTotal = 0;

    const tickProgress = () => {
      const seen = totals.totalFiles.value + totals.totalDirs.value;
      if (seen - lastEmittedTotal >= FLUSH_EVERY) {
        queue.push({
          type: 'metric',
          text: `${seen} entries`,
          data: { files: totals.totalFiles.value, dirs: totals.totalDirs.value },
        });
        lastEmittedTotal = seen;
      }
    };

    const walkPromise = walkDir(basePath, 0, {
      maxDepth,
      exclude,
      showFiles,
      showDirs,
      showHidden,
      filterGlob,
      lines,
      prefix: '',
      isLast: true,
      totalFiles: totals.totalFiles,
      totalDirs: totals.totalDirs,
      onProgress: tickProgress,
    });

    // Race the walk against periodic flushes — yield metrics while it runs.
    let walkDone = false;
    walkPromise.finally(() => {
      walkDone = true;
    });

    while (!walkDone || queue.length > 0) {
      if (queue.length > 0) {
        yield expectDefined(queue.shift());
      } else {
        // Race the walk completion against a short tick so we don't busy-
        // spin while the producer fills the queue. Previously the
        // setTimeout was never cleared when walkPromise won — one stray
        // timer per drain iteration accumulated on the event loop.
        let pollTimer: ReturnType<typeof setTimeout> | undefined;
        const poll = new Promise<void>((r) => {
          pollTimer = setTimeout(r, 50);
        });
        try {
          await Promise.race([walkPromise, poll]).catch(() => undefined);
        } finally {
          if (pollTimer) clearTimeout(pollTimer);
        }
      }
    }
    await walkPromise; // surface any error

    yield {
      type: 'final',
      output: {
        tree: lines.join('\n'),
        total_files: totals.totalFiles.value,
        total_dirs: totals.totalDirs.value,
        truncated: false,
        path: basePath,
      },
    };
  },
};

interface WalkOptions {
  maxDepth: number;
  exclude: Set<string>;
  showFiles: boolean;
  showDirs: boolean;
  showHidden: boolean;
  filterGlob?: string | undefined;
  lines: string[];
  prefix: string;
  isLast: boolean;
  totalFiles: { value: number };
  totalDirs: { value: number };
  onProgress?: (() => void) | undefined;
}

async function walkDir(dir: string, depth: number, opts: WalkOptions): Promise<void> {
  const entries = await fs
    .readdir(dir, { withFileTypes: true })
    .catch(() => [] as import('node:fs').Dirent[]);

  const filtered = entries.filter((e) => {
    if (!opts.showHidden && e.name.startsWith('.')) return false;
    if (opts.exclude.has(e.name)) return false;
    return true;
  });

  if (depth > 0) {
    const dirCount = filtered.filter((e) => e.isDirectory()).length;
    const fileCount = filtered.filter((e) => e.isFile()).length;
    opts.totalDirs.value += dirCount;
    opts.totalFiles.value += fileCount;
    opts.onProgress?.();
  }

  const items = filtered.sort((a, b) => {
    if (a.isDirectory() && !b.isDirectory()) return -1;
    if (!a.isDirectory() && b.isDirectory()) return 1;
    return a.name.localeCompare(b.name);
  });

  for (let i = 0; i < items.length; i++) {
    const entry = items[i];
    if (!entry) continue;
    const isLast = i === items.length - 1;
    const connector = opts.isLast ? '    ' : '│   ';
    const branch = isLast ? '└── ' : '├── ';
    const displayName = entry.name + (entry.isDirectory() ? '/' : '');

    if (!opts.showDirs && entry.isDirectory()) continue;
    if (!opts.showFiles && entry.isFile()) continue;

    opts.lines.push(opts.prefix + branch + displayName);

    if (entry.isDirectory() && (opts.maxDepth === 0 || depth < opts.maxDepth)) {
      const childPrefix = opts.prefix + connector;
      await walkDir(path.join(dir, entry.name), depth + 1, {
        ...opts,
        prefix: childPrefix,
        isLast,
      });
    }
  }
}
