import path from 'node:path';
import { spawn, execSync } from 'node:child_process';
import { writeFile, readFile, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { homedir, tmpdir } from 'node:os';
import { readBody, sendJSON } from '../utils/http.js';
import { textToAdoHtml } from '../utils/ado-formatter.js';
import { sanitizeAdoOrgUrl, adoRequest, adoFindEpic, adoCreateWorkItem, adoUploadAttachment, adoAddQAComment, parseWorkItemId, parseRequirementsForAdo } from '../services/ado-api.js';
import { adoGetFileContent, CLAUDE_BIN } from '../server/job-runner.js';
import { describeImageWithClaude } from '../server/chrome-cdp.js';

export async function handle(req, res, url, ctx) {
  const { cwd } = ctx;

  // POST /api/ado/sync
  if (req.method === 'POST' && url.pathname === '/api/ado/sync') {
    try {
      const body = await readBody(req);
      let { orgUrl, project, pat, epicName, featureName, specName, areaPath, tag, iteration, existingFeatureId, assignedTo } = JSON.parse(body);
      if (!orgUrl || !project || !pat) {
        sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true;
      }
      orgUrl = sanitizeAdoOrgUrl(orgUrl);
      console.log('[ADO] sync → orgUrl:', orgUrl, '| project:', project, '| area:', areaPath, '| iter:', iteration, '| tag:', tag);
      const result = { epic: null, feature: null, stories: [] };

      // Epic: buscar existente ou criar
      let epicId = null;
      if (epicName) {
        epicId = await adoFindEpic(orgUrl, project, pat, epicName);
        if (epicId) {
          const epicUrl = `${orgUrl}/${encodeURIComponent(project)}/_workitems/edit/${epicId}`;
          result.epic = { id: epicId, existing: true, title: epicName, url: epicUrl };
          console.log('[ADO] Epic existente #' + epicId);
        } else {
          const r = await adoCreateWorkItem(orgUrl, project, pat, 'Epic', epicName, '', '', null, areaPath, tag, iteration);
          console.log('[ADO] Criar Epic → status:', r.status, '| id:', r.body?.id);
          if (r.status >= 300) { const adoMsg = r.body?.message || r.body?.value?.Message || JSON.stringify(r.body); sendJSON(res, { error: 'Erro ao criar Epic: ' + adoMsg }, 500); return true; }
          epicId = r.body.id;
          result.epic = { id: epicId, existing: false, title: epicName, url: r.body._links?.html?.href };
        }
      }

      // Feature
      let featureId;
      if (existingFeatureId) {
        featureId = parseWorkItemId(String(existingFeatureId));
        if (!featureId) { sendJSON(res, { error: 'ID de Feature inválido: ' + existingFeatureId }, 400); return true; }
        const featureUrl = `${orgUrl}/${encodeURIComponent(project)}/_workitems/edit/${featureId}`;
        result.feature = { id: featureId, title: featureName || 'Feature existente', url: featureUrl, existing: true };
        console.log('[ADO] Usando Feature existente #' + featureId);
      } else {
        let featureDesc = '', featureCriteria = '';
        if (specName) {
          try {
            const briefRaw = await readFile(path.join(cwd, ctx.SDD_DIR, 'specs', specName, 'brief.md'), 'utf8');
            const sec = (h) => { const m = briefRaw.match(new RegExp('## ' + h + '\\n([\\s\\S]*?)(?=\\n##|$)')); return m ? m[1].trim() : ''; };
            const what  = sec('O que os usuários poderão fazer');
            const who   = sec('Quem vai usar');
            const rules = sec('Restrições e regras de negócio');
            featureCriteria = sec('Critérios de aceite');
            featureDesc = [what && ('O que os usuários poderão fazer:\n' + what), who && ('Quem vai usar:\n' + who), rules && ('Restrições:\n' + rules)].filter(Boolean).join('\n\n');
          } catch {}
        }
        const fr = await adoCreateWorkItem(orgUrl, project, pat, 'Feature', featureName, featureDesc, featureCriteria, epicId, areaPath, tag, iteration, assignedTo || '');
        console.log('[ADO] Criar Feature → status:', fr.status, '| id:', fr.body?.id);
        if (fr.status >= 300) { const adoMsg = fr.body?.message || fr.body?.value?.Message || JSON.stringify(fr.body); sendJSON(res, { error: 'Erro ao criar Feature: ' + adoMsg }, 500); return true; }
        featureId = fr.body.id;
        result.feature = { id: featureId, title: featureName, url: fr.body._links?.html?.href };
      }

      // User Stories
      const stories = await parseRequirementsForAdo(specName, cwd);
      console.log('[ADO] User Stories parseadas:', stories.length);
      for (const s of stories) {
        const sr = await adoCreateWorkItem(orgUrl, project, pat, 'User Story', s.title, s.description, s.criteria, featureId, areaPath, tag, iteration, assignedTo || '');
        console.log('[ADO] User Story "' + s.title + '" → status:', sr.status, '| id:', sr.body?.id);
        if (sr.status < 300) {
          result.stories.push({ id: sr.body.id, title: s.title, url: sr.body._links?.html?.href });
        }
      }

      // Salvar ado-sync.json para rastreabilidade bidirecional
      if (specName) {
        try {
          const syncPath = path.join(cwd, '.sdd', 'specs', specName, 'ado-sync.json');
          await writeFile(syncPath, JSON.stringify({
            syncedAt: new Date().toISOString(), orgUrl, project, areaPath: areaPath || '', tag: tag || '', iteration: iteration || '',
            epic: result.epic, feature: result.feature, stories: result.stories
          }, null, 2), 'utf8');
        } catch {}
      }

      console.log('[ADO] resultado final:', JSON.stringify(result));
      sendJSON(res, result);
    } catch (e) {
      sendJSON(res, { error: e.message }, 500);
    }
    return true;
  }

  // POST /api/ado/update-feature-desc — atualiza descrição da Feature com o conteúdo do design.md
  if (req.method === 'POST' && url.pathname === '/api/ado/update-feature-desc') {
    try {
      const body = await readBody(req);
      const { specName, orgUrl, project, pat } = JSON.parse(body);
      const syncData = JSON.parse(await readFile(path.join(cwd, ctx.SDD_DIR, 'specs', specName, 'ado-sync.json'), 'utf8'));
      const featureId = syncData.feature?.id;
      if (!featureId) throw new Error('Feature ID não encontrado — sincronize o ADO após Requisitos primeiro.');
      const designMd = await readFile(path.join(cwd, ctx.SDD_DIR, 'specs', specName, 'design.md'), 'utf8');
      // Try to extract the overview/intro section first, then fall back to first prose paragraphs
      const overviewMatch = designMd.match(/##\s*(?:Visão Geral|Overview|Introdução|Resumo|Introduction|Summary)[^\n]*\n([\s\S]*?)(?=\n##|$)/i);
      let designSummary = '';
      if (overviewMatch) {
        designSummary = overviewMatch[1].trim().split('\n').slice(0, 8).join('\n').trim();
      } else {
        // Fallback: collect prose lines, skipping headings, meta-reference lines, and separators
        const skipPatterns = /^#|^-{3,}|requisitos cobertos|requirements covered|tasks?:|fase:|phase:|---|\*\*requisitos\*\*|\*\*requirements\*\*/i;
        const lines = designMd.split('\n');
        const descLines = [];
        let chars = 0;
        for (const ln of lines) {
          if (skipPatterns.test(ln.trim())) continue;
          if (!ln.trim()) { if (descLines.length) descLines.push(''); continue; }
          descLines.push(ln);
          chars += ln.length;
          if (chars > 600) break;
        }
        designSummary = descLines.join('\n').trim();
      }
      const patchUrl = `${orgUrl}/${encodeURIComponent(project)}/_apis/wit/workitems/${featureId}?api-version=7.1`;
      const r = await adoRequest(orgUrl, pat, 'PATCH', patchUrl, [
        { op: 'add', path: '/fields/System.Description', value: textToAdoHtml(designSummary) },
      ]);
      if (r.status >= 300) throw new Error('ADO status ' + r.status + ': ' + (r.body?.message || JSON.stringify(r.body)));
      sendJSON(res, { ok: true, featureId, featureUrl: syncData.feature?.url });
    } catch (e) {
      sendJSON(res, { error: e.message }, 500);
    }
    return true;
  }

  // POST /api/ado/suggest-work-items — Claude gera sugestões de work items a partir de contexto
  if (req.method === 'POST' && url.pathname === '/api/ado/suggest-work-items') {
    try {
      const { context, area, customPromptTemplate, orgUrl: reqOrgUrl, project: reqProject, pat: reqPat, repoContexts, localFiles } = JSON.parse(await readBody(req));
      const hasRepoCtx = repoContexts?.length || localFiles?.length;
      if (!context?.trim() && !hasRepoCtx) { sendJSON(res, { error: 'Contexto vazio' }, 400); return true; }

      // Construir contexto extra de repositórios ADO e arquivos locais
      let extraContext = '';
      if (repoContexts?.length && reqPat) {
        const ouDefault = reqOrgUrl ? sanitizeAdoOrgUrl(reqOrgUrl) : '';
        for (const rc of repoContexts) {
          if (!rc.branch) continue;
          // Suporte a repos externos (via URL) e repos do projeto configurado
          let rcOu, rcProject, rcRepoId, rcRepoName;
          if (rc.repoUrl) {
            const m = rc.repoUrl.match(/dev\.azure\.com\/([^/]+)\/([^/?]+)\/_git\/([^/?]+)/);
            if (!m) continue;
            rcOu = 'https://dev.azure.com/' + m[1];
            rcProject = decodeURIComponent(m[2]);
            rcRepoId = decodeURIComponent(m[3]);
            rcRepoName = rc.repoName || rcRepoId;
          } else {
            if (!rc.repoId) continue;
            rcOu = rc.orgUrl ? sanitizeAdoOrgUrl(rc.orgUrl) : ouDefault;
            rcProject = rc.project || reqProject;
            if (!rcOu || !rcProject) continue;
            rcRepoId = rc.repoId;
            rcRepoName = rc.repoName || rc.repoId;
          }
          const effectivePat = (rc.repoPat && rc.repoPat.trim()) ? rc.repoPat.trim() : reqPat;
          extraContext += `\n\n## Repositório ADO: ${rcRepoName} (branch: ${rc.branch})\n`;
          try {
            if (rc.selectedFiles?.length) {
              for (const filePath of rc.selectedFiles.slice(0, 10)) {
                const fileUrl = `${rcOu}/${encodeURIComponent(rcProject)}/_apis/git/repositories/${encodeURIComponent(rcRepoId)}/items?path=${encodeURIComponent(filePath)}&versionDescriptor.version=${encodeURIComponent(rc.branch)}&versionDescriptor.versionType=branch&api-version=7.1`;
                const fileRes = await adoGetFileContent(effectivePat, fileUrl);
                if (fileRes.status < 300 && fileRes.body?.trim()) {
                  const ext = filePath.split('.').pop() || '';
                  const content = fileRes.body.length > 4000 ? fileRes.body.slice(0, 4000) + '\n...[truncado]' : fileRes.body;
                  extraContext += `\n### ${filePath}\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
                }
              }
            } else {
              const treeUrl = `${rcOu}/${encodeURIComponent(rcProject)}/_apis/git/repositories/${encodeURIComponent(rcRepoId)}/items?scopePath=/&recursionLevel=Full&versionDescriptor.version=${encodeURIComponent(rc.branch)}&versionDescriptor.versionType=branch&api-version=7.1`;
              const treeRes = await adoRequest(rcOu, effectivePat, 'GET', treeUrl, null);
              if (treeRes.status < 300 && treeRes.body?.value) {
                const filePaths = treeRes.body.value
                  .filter(i => i.gitObjectType === 'blob')
                  .map(i => i.path)
                  .slice(0, 80);
                extraContext += `### Estrutura de arquivos\n${filePaths.join('\n')}\n`;
              }
              const readmeUrl = `${rcOu}/${encodeURIComponent(rcProject)}/_apis/git/repositories/${encodeURIComponent(rcRepoId)}/items?path=/README.md&versionDescriptor.version=${encodeURIComponent(rc.branch)}&versionDescriptor.versionType=branch&api-version=7.1`;
              const readmeRes = await adoGetFileContent(effectivePat, readmeUrl);
              if (readmeRes.status < 300 && readmeRes.body?.trim()) {
                const excerpt = readmeRes.body.length > 2000 ? readmeRes.body.slice(0, 2000) + '\n...[truncado]' : readmeRes.body;
                extraContext += `### README.md\n${excerpt}\n`;
              }
            }
          } catch {}
        }
      }
      if (localFiles?.length) {
        extraContext += '\n\n## Arquivos locais selecionados\n';
        for (const fp of localFiles) {
          try {
            const content = await readFile(path.join(cwd, fp), 'utf8');
            const excerpt = content.length > 2500 ? content.slice(0, 2500) + '\n...[truncado]' : content;
            const ext = fp.split('.').pop() || '';
            extraContext += `\n### ${fp}\n\`\`\`${ext}\n${excerpt}\n\`\`\`\n`;
          } catch {}
        }
      }

      const fullContext = [context?.trim(), extraContext.trim()].filter(Boolean).join('\n\n');
      const areaGuides = {
        'Frontend':   { focus: 'telas, componentes, fluxos de navegação, estados visuais (vazio, loading, erro, sucesso), validações no cliente, responsividade, acessibilidade', titleEx: 'Criar componente de gráfico com tooltip interativo por segmento', critEx: 'Componente renderiza sem erros com dados mockados; tooltip exibe nome, valor e % ao hover; layout responsivo em 1280px e 768px' },
        'Backend':    { focus: 'endpoints de API, regras de negócio, queries/banco de dados, autenticação/autorização, integrações com serviços externos, performance, tratamento de erros, contratos de dados', titleEx: 'Criar endpoint GET /api/evolution/monthly que agrega dados por categoria e mês', critEx: 'Endpoint retorna HTTP 200 com JSON no contrato definido; query agrega corretamente por mês e categoria; resposta em < 300ms para 12 meses de dados' },
        'Full-stack': { focus: 'frontend e backend — separe em itens distintos por camada quando os escopos forem claros', titleEx: '[FE] Componente de gráfico / [BE] Endpoint de dados agregados', critEx: 'Frontend consome endpoint e exibe dados; backend retorna contrato correto; integração funciona end-to-end' },
        'QA':         { focus: 'cenários de teste BDD (Dado/Quando/Então), automação, casos de borda, regressão, testes de performance, critérios de aceite verificáveis', titleEx: 'Validar renderização correta do gráfico para diferentes volumes de dados', critEx: 'Dado que os dados do mês estão disponíveis / Quando acesso a tela / Então o gráfico exibe todas as categorias com as cores corretas' },
        'Dados':      { focus: 'queries SQL, views, stored procedures, pipelines de ingestão/transformação, modelagem dimensional, indicadores, performance de consultas', titleEx: 'Criar view vw_evolution_monthly agregando segmentos por mês e categoria', critEx: 'View retorna dados corretos para o período baseline; performance < 2s para 24 meses; nulos tratados como zero' },
      };
      const ag = area && areaGuides[area];
      const _areaPfxMap = {'Backend':'BE','Frontend':'FE','Full-stack':'FS','QA':'QA','Dados':'DA'};
      const _pfx = area && _areaPfxMap[area] ? _areaPfxMap[area] : null;
      const result = await new Promise(resolve => {
        let prompt;
        // Restrição de área: quando área selecionada, força stories apenas dessa camada
        const _areaOnlyRule = _pfx
          ? `\n- RESTRIÇÃO CRÍTICA: gere SOMENTE stories de ${area} (prefixo [${_pfx}]). NÃO inclua stories de outras camadas (Backend, BFF, Frontend, etc.). Cada seção ## US-XXX deve ser exclusivamente de ${area}.`
          : '';
        // JSON_INSTRUCTION usa o prefixo da área selecionada no exemplo (evita confundir Claude com [BE]/[BFF])
        const _exPfx = _pfx || 'XX';
        const _hasHtmlProto = fullContext.includes('PROTÓTIPO HTML (');
        // Extract section titles from HTML prototype — h1-h6 + elements with title/header class + strong/b
        let _htmlHeadingList = '';
        if (_hasHtmlProto) {
          const _seen = new Set();
          const _headings = [];
          let _totalChars = 0;
          const _addTitle = (raw) => {
            const t = raw.replace(/<[^>]+>/g, '').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
            if (t.length > 2 && t.length < 100 && !_seen.has(t) && _totalChars < 4000) {
              _seen.add(t); _headings.push(t); _totalChars += t.length + 4;
            }
          };
          let _hm;
          // h1-h6
          const _hRe = /<h[1-6][^>]*>([\s\S]{1,200}?)<\/h[1-6]>/gi;
          while ((_hm = _hRe.exec(fullContext)) !== null) _addTitle(_hm[1]);
          // elements with class containing title/header/subtitle/sec-
          const _clsRe = /<[a-z][a-z0-9]*\s[^>]*class="[^"]*(?:title|header|subtitle|sec-title|section-title|card-title|panel-title)[^"]*"[^>]*>([^<]{2,100})</gi;
          while ((_hm = _clsRe.exec(fullContext)) !== null) _addTitle(_hm[1]);
          // <strong> and <b> — short text only (title-like emphasis)
          const _boldRe = /<(?:strong|b)>([^<]{3,80})<\/(?:strong|b)>/gi;
          while ((_hm = _boldRe.exec(fullContext)) !== null) _addTitle(_hm[1]);
          if (_headings.length > 0) {
            _htmlHeadingList = '\n- TÍTULOS EXTRAÍDOS DO HTML — aparecem EXATAMENTE assim no protótipo; reproduza-os sem alterar uma única letra:\n' +
              _headings.map(t => `  · "${t}"`).join('\n');
          }
        }
        const _htmlLiteralRule = _hasHtmlProto
          ? `\n- TEXTOS LITERAIS OBRIGATÓRIOS: cada título de seção, subtítulo, label, nome de card, texto de botão, cabeçalho de coluna e qualquer string visível no HTML DEVE aparecer EXATAMENTE como está no HTML — sem abreviação, sem tradução, sem paráfrase, sem variação. Esta regra é inviolável.${_htmlHeadingList}\n- COBERTURA TOTAL OBRIGATÓRIA DO HTML: faça uma varredura linear do HTML do primeiro ao último byte. Para CADA seção, sub-seção, tab, painel, card, tabela, gráfico, lista de barras, funil, ou bloco de conteúdo encontrado — crie uma US. Isso inclui: tabs não-ativas, elementos com display:none ou hidden, painéis colapsados, sub-componentes dentro de uma seção (ex: se uma seção tem um donut chart E uma lista de barras abaixo, são dois sub-elementos que DEVEM ser cobertos na mesma US ou em USs separadas). É PROIBIDO pular qualquer elemento HTML visível, mesmo que pareça redundante ou secundário.`
          : '';
        const _hasExisting = fullContext.includes('## US EXISTENTES NA FEATURE');
        const _hasNumbering = fullContext.includes('## NUMERAÇÃO DE CONTINUIDADE');
        const _incrementRule = _hasExisting
          ? '\n- MODO INCREMENTO ATIVO: as US listadas em "## US EXISTENTES NA FEATURE" já existem no ADO e NÃO devem ser recriadas ou duplicadas. Gere APENAS as novas US incrementais que complementam o escopo existente.' + (_hasNumbering ? '\n- NUMERAÇÃO OBRIGATÓRIA: siga EXATAMENTE a sequência indicada em "## NUMERAÇÃO DE CONTINUIDADE". Inclua o número no título de cada novo item no formato "US 006: título", "US 007: título", etc.' : '')
          : '';
        const JSON_INSTRUCTION = `\n\n---\nGere EXATAMENTE 1 work item que consolide todo o escopo identificado.
Regras obrigatórias:
- "title": título resumido da feature/épico
- "description": consolide TODAS as User Stories e detalhes técnicos em um único campo rico. Use seções com "## US-001: ...", "## US-002: ...", etc. Inclua endpoints, estrutura de pacotes, SQL, DTOs, fluxos, etc. Use \\n para quebras de linha.${_areaOnlyRule}${_htmlLiteralRule}${_incrementRule}
- "criteria": consolide TODOS os critérios de aceite de todas as USs em um único campo. Agrupe por seção "### US-001\\n- critério\\n### US-002\\n- critério". Use \\n para quebras de linha.
- "layer": classifique como "Frontend", "Backend" ou "Both" conforme a camada principal de implementação do work item.
- Responda APENAS com o array JSON com 1 objeto, sem texto fora dele:
[{"type":"User Story","title":"Título da feature","description":"## US-001: [${_exPfx}] Título\\nDescrição técnica detalhada...\\n\\n## US-002: [${_exPfx}] Título\\n...","criteria":"### US-001\\n- Critério 1\\n- Critério 2\\n\\n### US-002\\n- Critério 1\\n- Critério 2","layer":"Backend"}]`;
        if (customPromptTemplate && customPromptTemplate.trim()) {
          if (customPromptTemplate.includes('{{contexto}}')) {
            prompt = customPromptTemplate.replace('{{contexto}}', fullContext);
          } else {
            // Template sem placeholder — appenda contexto explicitamente antes do JSON_INSTRUCTION
            prompt = customPromptTemplate + '\n\n## Contexto da Feature (geração automática)\n' + fullContext;
          }
          prompt += JSON_INSTRUCTION;
        } else if (ag) {
          prompt = `Dado o requisito de produto abaixo, gere as User Stories técnicas de ${area} necessárias para implementá-lo.

REQUISITO:
${fullContext}

Foco de implementação: ${ag.focus}.

Exemplo de título: "${ag.titleEx}"
Exemplo de critérios: "${ag.critEx}"

- title: prefixo [${({'Backend':'BE','Frontend':'FE','Full-stack':'FS','QA':'QA','Dados':'DA'}[area]||area.slice(0,2).toUpperCase())}] + verbo técnico + o que implementar
- description: descrição técnica completa do que o desenvolvedor de ${area} precisa construir (inclua endpoints, estrutura de código, queries, DTOs, etc.)
- criteria: todos os critérios técnicos verificáveis em um único bloco
${JSON_INSTRUCTION}`;
        } else {
          prompt = `Você é um analista de requisitos sênior. Com base no contexto abaixo, gere os work items para Azure DevOps.

## Contexto
${fullContext}

## Instruções
- description: use o conteúdo do contexto diretamente; inclua todos os detalhes técnicos relevantes
- criteria: inclua todos os critérios de aceite verificáveis; se não houver, derive critérios claros
${JSON_INSTRUCTION}`;
        }

        const proc = spawn(CLAUDE_BIN, ['--print', '--dangerously-skip-permissions'], {
          cwd, stdio: ['pipe', 'pipe', 'pipe'],
        });
        proc.stdin.write(prompt, 'utf8');
        proc.stdin.end();
        let out = '';
        let killedByTimeout = false;
        proc.stdout.on('data', d => { out += d.toString(); });
        proc.on('close', () => {
          // Extrai o primeiro array JSON válido usando contagem de brackets
          // (mais robusto que regex greedy quando Claude adiciona texto com [ ] antes do JSON)
          function extractJsonArray(text) {
            // Tenta bloco de código markdown primeiro: ```json [...]```
            const cb = text.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
            if (cb) { try { JSON.parse(cb[1]); return cb[1]; } catch {} }
            // Coleta todos os arrays top-level usando contagem de brackets
            const candidates = [];
            let depth = 0, start = -1;
            for (let i = 0; i < text.length; i++) {
              if (text[i] === '[' && depth === 0) { depth = 1; start = i; }
              else if (text[i] === '[' && depth > 0) depth++;
              else if (text[i] === ']' && depth > 0) { depth--; if (depth === 0 && start >= 0) candidates.push(text.slice(start, i + 1)); }
            }
            // Prefere o último candidato válido (Claude costuma colocar o JSON no final)
            for (let i = candidates.length - 1; i >= 0; i--) {
              try { JSON.parse(candidates[i]); return candidates[i]; } catch {}
            }
            return null;
          }
          const jsonStr = extractJsonArray(out);
          if (jsonStr) {
            try {
              const items = JSON.parse(jsonStr).map(it => ({
                ...it,
                description: (it.description || '').replace(/\\n/g, '\n').replace(/\\t/g, '\t'),
                criteria: (it.criteria || '').replace(/\\n/g, '\n').replace(/\\t/g, '\t'),
              }));
              resolve({ items });
              return;
            } catch {}
          }
          resolve({ error: killedByTimeout
            ? 'Geração demorou muito (15 min). Tente reduzir o contexto ou aguarde e tente novamente.'
            : 'Não foi possível gerar sugestões. Tente novamente.' });
        });
        proc.on('error', () => resolve({ error: 'Claude não disponível' }));
        const _killTimer = setTimeout(() => { killedByTimeout = true; try { proc.kill(); } catch {} }, 900000);
      });
      sendJSON(res, result);
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // POST /api/wi/regen-criteria — regenera critérios de aceite
  if (req.method === 'POST' && url.pathname === '/api/wi/regen-criteria') {
    try {
      const { title, description } = JSON.parse(await readBody(req));
      if (!description?.trim() && !title?.trim()) { sendJSON(res, { error: 'Título ou descrição obrigatório' }, 400); return true; }
      const prompt = `Você é um analista de requisitos sênior. Com base no work item abaixo, gere critérios de aceite claros e verificáveis.

Título: ${(title || '').trim()}
Descrição: ${(description || '').trim()}

Instruções:
- Gere entre 3 e 6 critérios de aceite objetivos
- Cada critério deve ser verificável por um desenvolvedor ou testador
- Use linguagem de comportamento: "Deve...", "Quando... então..."
- Responda APENAS com os critérios, um por linha, sem JSON, sem bullets, sem numeração`;

      const result = await new Promise(resolve => {
        const proc = spawn(CLAUDE_BIN, ['--print', '--dangerously-skip-permissions', prompt], {
          cwd, stdio: ['ignore', 'pipe', 'pipe'],
        });
        let out = '';
        proc.stdout.on('data', d => { out += d.toString(); });
        proc.on('close', () => resolve({ criteria: out.trim() }));
        proc.on('error', () => resolve({ error: 'Claude não disponível' }));
        setTimeout(() => { try { proc.kill(); } catch {} }, 60000);
      });
      sendJSON(res, result);
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // POST /api/ado/create-work-items — cria múltiplos work items vinculados a uma Feature existente
  if (req.method === 'POST' && url.pathname === '/api/ado/create-work-items') {
    try {
      let { orgUrl, project, pat, featureId: featureRef, items, areaPath, iteration, tag, assignedTo, createTasks, createSubWorkItems } = JSON.parse(await readBody(req));
      if (!orgUrl || !project || !pat) { sendJSON(res, { error: 'Credenciais ADO ausentes' }, 400); return true; }
      if (!featureRef) { sendJSON(res, { error: 'Feature ID obrigatório' }, 400); return true; }
      if (!items?.length) { sendJSON(res, { error: 'Nenhum item para criar' }, 400); return true; }
      orgUrl = sanitizeAdoOrgUrl(orgUrl);
      const featureId = parseWorkItemId(String(featureRef));
      if (!featureId) { sendJSON(res, { error: 'ID de Feature inválido: ' + featureRef }, 400); return true; }
      const created = [];
      const errors = [];
      for (const item of items) {
        if (!item.title?.trim()) continue;
        const itemTag = (item.tags || '').trim() || tag || '';
        const r = await adoCreateWorkItem(
          orgUrl, project, pat,
          item.type || 'User Story',
          item.title.trim(),
          item.description || '',
          item.criteria || '',
          featureId,
          areaPath || '', itemTag, iteration || '',
          item.assignee || assignedTo || '',
          item.customFields || {}
        );
        if (r.status < 300) {
          const storyId = r.body.id;
          const storyEntry = { id: storyId, title: item.title, type: item.type || 'User Story', url: r.body._links?.html?.href, tasks: [] };
          // Criar Tasks filhas (aparecem no board como ☑ 0/N)
          if (createTasks && item.description) {
            // Parseia seções ## US-001: Título da description
            function parseUsSections(desc) {
              const sections = [];
              let cur = null;
              for (const line of desc.split('\n')) {
                const m = line.trim().match(/^#{1,3}\s+((?:US|us)-?\d+[:\s].*)/);
                if (m) {
                  if (cur) sections.push(cur);
                  const fullTitle = m[1].trim();
                  const key = (fullTitle.match(/(?:US|us)-?\d+/) || [''])[0].toUpperCase().replace('-','').replace(/^US(\d+)$/, 'US-$1');
                  cur = { key, fullTitle, descLines: [] };
                } else if (cur) {
                  cur.descLines.push(line);
                }
              }
              if (cur) sections.push(cur);
              return sections.map(s => ({ ...s, description: s.descLines.join('\n').trim() }));
            }
            // Parseia seções ### US-001 dos critérios
            function parseUsCriteria(criteria) {
              const map = {};
              let curKey = null, curLines = [];
              for (const line of (criteria || '').split('\n')) {
                const m = line.trim().match(/^#{1,3}\s+((?:US|us)-?\d+)/);
                if (m) {
                  if (curKey) map[curKey] = curLines.join('\n').trim();
                  curKey = m[1].toUpperCase().replace('-','').replace(/^US(\d+)$/, 'US-$1');
                  curLines = [];
                } else if (curKey) {
                  curLines.push(line);
                }
              }
              if (curKey) map[curKey] = curLines.join('\n').trim();
              return map;
            }

            // Normaliza \\n literal → \n real antes de parsear
            const rawDesc = (item.description || '').replace(/\\n/g, '\n');
            const rawCrit = (item.criteria || '').replace(/\\n/g, '\n');
            const usSections = parseUsSections(rawDesc);
            if (usSections.length > 0) {
              // Formato consolidado: uma Task por seção US com sua própria desc + CA
              const critMap = parseUsCriteria(rawCrit);
              for (const sec of usSections) {
                const title = sec.fullTitle.length > 120 ? sec.fullTitle.slice(0, 117) + '...' : sec.fullTitle;
                const taskDesc = sec.description || '';
                const taskAc = critMap[sec.key] || '';
                const tr = await adoCreateWorkItem(orgUrl, project, pat, 'Task', title, taskDesc, taskAc, storyId, areaPath || '', itemTag, iteration || '', assignedTo || '', {});
                if (tr.status < 300) {
                  storyEntry.tasks.push({ id: tr.body.id, title, url: tr.body._links?.html?.href });
                }
              }
            } else {
              // Fallback: bullet points como tasks sem desc/CA
              const taskTitles = item.description.split('\n')
                .map(l => l.trim().replace(/^[-*•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
                .filter(l => l.length > 3 && !/^#/.test(l));
              for (const taskTitle of taskTitles) {
                const title = taskTitle.length > 120 ? taskTitle.slice(0, 117) + '...' : taskTitle;
                const tr = await adoCreateWorkItem(orgUrl, project, pat, 'Task', title, '', '', storyId, areaPath || '', itemTag, iteration || '', assignedTo || '', {});
                if (tr.status < 300) {
                  storyEntry.tasks.push({ id: tr.body.id, title, url: tr.body._links?.html?.href });
                }
              }
            }
          }
          // Criar Work Items filhos a partir da descrição
          if (createSubWorkItems && item.description) {
            const subTitles = item.description
              .split('\n')
              .map(l => l.trim().replace(/^[-*•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
              .filter(l => l.length > 3);
            for (const subTitle of subTitles) {
              const title = subTitle.length > 120 ? subTitle.slice(0, 117) + '...' : subTitle;
              const sr = await adoCreateWorkItem(orgUrl, project, pat, item.type || 'User Story', title, '', '', storyId, areaPath || '', itemTag, iteration || '', assignedTo || '', {});
              if (sr.status < 300) {
                storyEntry.tasks.push({ id: sr.body.id, title, url: sr.body._links?.html?.href });
              }
            }
          }
          created.push(storyEntry);
        } else {
          const msg = r.body?.message || r.body?.value?.Message || JSON.stringify(r.body);
          errors.push({ title: item.title, error: msg });
        }
      }
      sendJSON(res, { created, errors });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/members?orgUrl=...&project=...&pat=...
  if (req.method === 'GET' && url.pathname === '/api/ado/members') {
    try {
      let { orgUrl: ou, project: pr, pat: pa } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa) { sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      // Busca todos os times do projeto
      const teamsRes = await adoRequest(ou, pa, 'GET', `${ou}/_apis/projects/${encodeURIComponent(pr)}/teams?$top=20&api-version=7.1`, null);
      if (teamsRes.status >= 300) { sendJSON(res, { error: 'Erro ao listar times: ' + (teamsRes.body?.message || teamsRes.status) }, 500); return true; }
      const teams = teamsRes.body?.value || [];
      const seen = new Set();
      const members = [];
      for (const team of teams.slice(0, 5)) {
        const mRes = await adoRequest(ou, pa, 'GET', `${ou}/_apis/projects/${encodeURIComponent(pr)}/teams/${encodeURIComponent(team.id)}/members?api-version=7.1`, null);
        if (mRes.status !== 200) continue;
        for (const m of mRes.body?.value || []) {
          const identity = m.identity || m;
          const uniqueName = identity.uniqueName || identity.principalName || '';
          const displayName = identity.displayName || uniqueName;
          if (!uniqueName || seen.has(uniqueName)) continue;
          seen.add(uniqueName);
          members.push({ displayName, uniqueName });
        }
      }
      members.sort((a, b) => a.displayName.localeCompare(b.displayName, 'pt-BR'));
      sendJSON(res, { members });
    } catch(e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/repos — lista repositórios Git
  if (req.method === 'GET' && url.pathname === '/api/ado/repos') {
    try {
      let { orgUrl: ou, project: pr, pat: pa } = Object.fromEntries(url.searchParams);
      if (!ou || !pa) { sendJSON(res, { error: 'orgUrl e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const endpoint = pr
        ? `${ou}/${encodeURIComponent(pr)}/_apis/git/repositories?api-version=7.1`
        : `${ou}/_apis/git/repositories?api-version=7.1`;
      const r = await adoRequest(ou, pa, 'GET', endpoint, null);
      if (r.status >= 300) { sendJSON(res, { error: 'Erro ao listar repositórios: ' + (r.body?.message || r.status) }, 500); return true; }
      let repos = (r.body?.value || []).map(repo => ({
        id: repo.id,
        name: repo.name,
        defaultBranch: (repo.defaultBranch || 'refs/heads/main').replace('refs/heads/', ''),
        project: repo.project?.name || pr || ''
      }));
      // Filtrar apenas repos cujo projeto o usuário é membro de algum time
      try {
        const teamsRes = await adoRequest(ou, pa, 'GET',
          `${ou}/_apis/teams?$mine=true&$top=500&api-version=7.1-preview.3`, null);
        if (teamsRes.status < 300 && Array.isArray(teamsRes.body?.value)) {
          const myProjects = new Set(teamsRes.body.value.map(t => t.projectName).filter(Boolean));
          if (myProjects.size > 0) repos = repos.filter(r => myProjects.has(r.project));
        }
      } catch {} // fallback: exibe todos se a verificação falhar
      repos = repos.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR'));
      sendJSON(res, { repos });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // POST /api/ado/activity-poll — monitora pushes e PRs recentes de todos os orgs/PATs
  if (req.method === 'POST' && url.pathname === '/api/ado/activity-poll') {
    try {
      const { orgs, since, repoFilter } = JSON.parse(await readBody(req));
      if (!orgs?.length) { sendJSON(res, { items: [] }); return true; }
      const sinceDate = since || new Date(Date.now() - 4 * 3600 * 1000).toISOString();
      // repoFilter: array de "name@orgSlug" — deduplica repos homônimos de projetos diferentes
      const activeFilter = Array.isArray(repoFilter) && repoFilter.length > 0 ? new Set(repoFilter) : null;
      const sinceAgeDays = (Date.now() - new Date(sinceDate).getTime()) / 86400000;
      const prTop    = sinceAgeDays <= 1 ? 50  : sinceAgeDays <= 7  ? 150 : sinceAgeDays <= 30 ? 300 : 500;
      const pushTop  = sinceAgeDays <= 1 ? 10  : sinceAgeDays <= 7  ? 30  : sinceAgeDays <= 30 ? 60  : 100;
      const repoLimit= sinceAgeDays <= 1 ? 20  : sinceAgeDays <= 7  ? 40  : 100;
      const totalCap = sinceAgeDays <= 1 ? 200 : 1000;
      const allItems = [];

      for (const org of orgs) {
        const ou = sanitizeAdoOrgUrl(org.orgUrl);
        const pa = org.pat;
        if (!ou || !pa) continue;
        const ouSlug = ou.split('/').filter(Boolean).pop() || '';

        // Descobre projetos (explícito ou via teams)
        let projects = [];
        if (org.project) {
          projects = [org.project];
        } else {
          try {
            const tr = await adoRequest(ou, pa, 'GET',
              `${ou}/_apis/teams?$mine=true&$top=500&api-version=7.1-preview.3`, null);
            if (tr.status < 300 && tr.body?.value) {
              projects = [...new Set(tr.body.value.map(t => t.projectName).filter(Boolean))];
            }
          } catch {}
        }

        for (const proj of projects) {
          // PRs do projeto (project-level, 1 chamada cobre os repositórios)
          try {
            const prUrl = `${ou}/${encodeURIComponent(proj)}/_apis/git/pullrequests?api-version=7.1`
              + `&searchCriteria.status=all&$top=${prTop}`;
            const prRes = await adoRequest(ou, pa, 'GET', prUrl, null);
            if (prRes.status < 300 && prRes.body?.value) {
              for (const pr of prRes.body.value) {
                if (activeFilter && !activeFilter.has((pr.repository?.name || '') + '@' + ouSlug)) continue;
                const relevantDate = (pr.status !== 'active' && pr.closedDate && pr.closedDate > sinceDate)
                  ? pr.closedDate : pr.creationDate;
                if (relevantDate < sinceDate) continue;
                const statusLabel = { active: 'abriu PR', completed: 'mergeou PR', abandoned: 'abandonou PR' }[pr.status] || 'PR';
                allItems.push({
                  id: `pr-${pr.repository?.id}-${pr.pullRequestId}`,
                  type: 'pr',
                  prStatus: pr.status,
                  action: statusLabel,
                  repo: pr.repository?.name || '',
                  project: proj,
                  orgUrl: ou,
                  author: pr.createdBy?.displayName || pr.createdBy?.uniqueName || 'Desconhecido',
                  timestamp: relevantDate || pr.creationDate,
                  title: pr.title || '',
                  description: pr.description ? pr.description.slice(0, 200) : '',
                  branch: (pr.sourceRefName || '').replace('refs/heads/', ''),
                  targetBranch: (pr.targetRefName || '').replace('refs/heads/', ''),
                  url: `${ou}/${encodeURIComponent(proj)}/_git/${encodeURIComponent(pr.repository?.name || '')}/pullrequest/${pr.pullRequestId}`
                });
              }
            }
          } catch {}

          // Pushes por repo (busca repos do projeto, limita a 20 repos)
          let repos = [];
          try {
            const reposRes = await adoRequest(ou, pa, 'GET',
              `${ou}/${encodeURIComponent(proj)}/_apis/git/repositories?api-version=7.1`, null);
            if (reposRes.status < 300) {
              repos = reposRes.body?.value || [];
              if (activeFilter) repos = repos.filter(r => activeFilter.has(r.name + '@' + ouSlug));
              repos = repos.slice(0, repoLimit);
            }
          } catch {}

          await Promise.all(repos.map(async repo => {
            try {
              const pushUrl = `${ou}/${encodeURIComponent(proj)}/_apis/git/repositories/${repo.id}/pushes`
                + `?api-version=7.1&fromDate=${encodeURIComponent(sinceDate)}&$top=${pushTop}`;
              const pushRes = await adoRequest(ou, pa, 'GET', pushUrl, null);
              if (pushRes.status < 300 && pushRes.body?.value) {
                for (const push of pushRes.body.value) {
                  const branch = (push.refUpdates?.[0]?.name || '').replace('refs/heads/', '');
                  const commits = push.commits || [];
                  const mainMsg = commits[0]?.comment || '';
                  const extraMsgs = commits.slice(1, 4).map(c => c.comment).filter(Boolean);
                  allItems.push({
                    id: `push-${repo.id}-${push.pushId}`,
                    type: 'push',
                    action: 'fez push',
                    repo: repo.name,
                    project: proj,
                    orgUrl: ou,
                    author: push.pushedBy?.displayName || push.pushedBy?.uniqueName || 'Desconhecido',
                    timestamp: push.date,
                    title: mainMsg,
                    description: extraMsgs.join(' • '),
                    branch,
                    commitCount: commits.length,
                    url: `${ou}/${encodeURIComponent(proj)}/_git/${encodeURIComponent(repo.name)}/pushes/${push.pushId}`
                  });
                }
              }
            } catch {}
          }));
        }
      }

      allItems.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
      sendJSON(res, { items: allItems.slice(0, totalCap), checkedAt: new Date().toISOString() });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/my-project-repos
  if (req.method === 'GET' && url.pathname === '/api/ado/my-project-repos') {
    try {
      let { orgUrl: ou, pat: pa, allProjects } = Object.fromEntries(url.searchParams);
      if (!ou || !pa) { sendJSON(res, { error: 'orgUrl e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      let myProjects = [];
      if (allProjects === 'true') {
        try {
          const projRes = await adoRequest(ou, pa, 'GET',
            `${ou}/_apis/projects?api-version=7.1&$top=500&stateFilter=wellFormed`, null);
          if (projRes.status < 300 && projRes.body?.value?.length) {
            myProjects = projRes.body.value.map(p => p.name).filter(Boolean);
          }
        } catch {}
        if (!myProjects.length) {
          // fallback: teams
          const teamsRes = await adoRequest(ou, pa, 'GET',
            `${ou}/_apis/teams?$mine=true&$top=500&api-version=7.1-preview.3`, null);
          myProjects = [...new Set((teamsRes.body?.value || []).map(t => t.projectName).filter(Boolean))];
        }
      } else {
        // 1. Descobre projetos onde o usuário é membro de algum time
        const teamsRes = await adoRequest(ou, pa, 'GET',
          `${ou}/_apis/teams?$mine=true&$top=500&api-version=7.1-preview.3`, null);
        if (teamsRes.status >= 300) { sendJSON(res, { repos: [] }); return true; }
        myProjects = [...new Set(
          (teamsRes.body?.value || []).map(t => t.projectName).filter(Boolean)
        )];
      }
      if (!myProjects.length) { sendJSON(res, { repos: [] }); return true; }
      // 2. Busca repos de cada projeto em paralelo
      const repoLists = await Promise.all(myProjects.map(async proj => {
        try {
          const r = await adoRequest(ou, pa, 'GET',
            `${ou}/${encodeURIComponent(proj)}/_apis/git/repositories?api-version=7.1`, null);
          if (r.status >= 300) return [];
          return (r.body?.value || []).map(repo => ({
            id: repo.id, name: repo.name,
            defaultBranch: (repo.defaultBranch || 'refs/heads/main').replace('refs/heads/', ''),
            project: proj
          }));
        } catch { return []; }
      }));
      const seen = new Set();
      const repos = repoLists.flat()
        .filter(r => { if (seen.has(r.id)) return false; seen.add(r.id); return true; })
        .sort((a, b) => a.name.localeCompare(b.name, 'pt-BR'));
      sendJSON(res, { repos });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/repo-branches — lista branches de um repositório Git ADO
  if (req.method === 'GET' && url.pathname === '/api/ado/repo-branches') {
    try {
      let { orgUrl: ou, project: pr, pat: pa, repoId } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa || !repoId) { sendJSON(res, { error: 'Parâmetros obrigatórios ausentes' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const r = await adoRequest(ou, pa, 'GET',
        `${ou}/${encodeURIComponent(pr)}/_apis/git/repositories/${encodeURIComponent(repoId)}/refs?filter=heads&$top=200&api-version=7.1`, null);
      if (r.status >= 300) { sendJSON(res, { error: 'Erro ao listar branches: ' + (r.body?.message || r.status) }, 500); return true; }
      const branches = (r.body?.value || [])
        .map(ref => ref.name.replace('refs/heads/', ''))
        .sort((a, b) => {
          // main/master primeiro, resto em ordem alfabética
          if (a === 'main' || a === 'master') return -1;
          if (b === 'main' || b === 'master') return 1;
          return a.localeCompare(b, 'pt-BR');
        });
      sendJSON(res, { branches });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // POST /api/ado/repo-files — lista arquivos de um repo ADO por URL
  if (req.method === 'POST' && url.pathname === '/api/ado/repo-files') {
    try {
      const { repoUrl, branch, pat: reqPat } = JSON.parse(await readBody(req));
      if (!repoUrl || !branch || !reqPat) { sendJSON(res, { error: 'repoUrl, branch e pat obrigatórios' }, 400); return true; }
      const m = repoUrl.match(/dev\.azure\.com\/([^/]+)\/([^/?]+)\/_git\/([^/?]+)/);
      if (!m) { sendJSON(res, { error: 'URL inválida' }, 400); return true; }
      const rcOu = 'https://dev.azure.com/' + m[1];
      const rcProject = decodeURIComponent(m[2]);
      const rcRepoId = decodeURIComponent(m[3]);
      const treeUrl = `${rcOu}/${encodeURIComponent(rcProject)}/_apis/git/repositories/${encodeURIComponent(rcRepoId)}/items?scopePath=/&recursionLevel=Full&versionDescriptor.version=${encodeURIComponent(branch)}&versionDescriptor.versionType=branch&api-version=7.1`;
      const treeRes = await adoRequest(rcOu, reqPat, 'GET', treeUrl, null);
      if (treeRes.status >= 300) { sendJSON(res, { error: 'Sem acesso ao repositório (' + treeRes.status + ')' }, 403); return true; }
      const files = (treeRes.body?.value || []).filter(i => i.gitObjectType === 'blob').map(i => i.path);
      sendJSON(res, { files });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/workitem-types?orgUrl=...&project=...&pat=...
  if (req.method === 'GET' && url.pathname === '/api/ado/workitem-types') {
    try {
      let { orgUrl: ou, project: pr, pat: pa } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa) { sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const r = await adoRequest(ou, pa, 'GET', `${ou}/${encodeURIComponent(pr)}/_apis/wit/workitemtypes?api-version=7.1`, null);
      if (r.status >= 300) { sendJSON(res, { types: ['User Story', 'Task', 'Bug'] }); return true; }
      const types = (r.body?.value || []).map(t => t.name).filter(Boolean).sort();
      sendJSON(res, { types: types.length ? types : ['User Story', 'Task', 'Bug'] });
    } catch(e) { sendJSON(res, { types: ['User Story', 'Task', 'Bug'] }); }
    return true;
  }

  // GET /api/ado/workitem-type-fields?orgUrl=...&project=...&pat=...&type=...
  if (req.method === 'GET' && url.pathname === '/api/ado/workitem-type-fields') {
    try {
      let { orgUrl: ou, project: pr, pat: pa, type: wtype } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa || !wtype) { sendJSON(res, { fields: [] }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const r = await adoRequest(ou, pa, 'GET',
        `${ou}/${encodeURIComponent(pr)}/_apis/wit/workitemtypes/${encodeURIComponent(wtype)}/fields?$expand=allowedValues&api-version=7.1`, null);
      if (r.status >= 300) { sendJSON(res, { fields: [] }); return true; }
      // Skip standard system fields that we already handle
      const fields = (r.body?.value || [])
        .filter(f => {
          if (!f.referenceName) return false;
          const ref = f.referenceName;
          if (ref.startsWith('System.') || ref.startsWith('Microsoft.')) return false;
          return f.required || (Array.isArray(f.allowedValues) && f.allowedValues.length > 0);
        })
        .map(f => ({
          referenceName: f.referenceName,
          name: f.name || f.referenceName.split('.').pop(),
          required: !!f.required,
          allowedValues: (f.allowedValues || []).filter(Boolean),
        }));
      sendJSON(res, { fields });
    } catch(e) { sendJSON(res, { fields: [] }); }
    return true;
  }

  // GET /api/ado/tags?orgUrl=...&project=...&pat=...
  if (req.method === 'GET' && url.pathname === '/api/ado/tags') {
    try {
      let { orgUrl: ou, project: pr, pat: pa } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa) { sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const r = await adoRequest(ou, pa, 'GET', `${ou}/${encodeURIComponent(pr)}/_apis/wit/tags?api-version=7.1`, null);
      if (r.status >= 300) { sendJSON(res, { tags: [] }); return true; }
      const tags = (r.body?.value || []).map(t => t.name).filter(Boolean).sort((a,b) => a.localeCompare(b, 'pt-BR'));
      sendJSON(res, { tags });
    } catch(e) { sendJSON(res, { tags: [] }); }
    return true;
  }

  // GET /api/ado/nodes?type=areas|iterations&orgUrl=...&project=...&pat=...
  if (req.method === 'GET' && url.pathname === '/api/ado/nodes') {
    try {
      let { orgUrl: ou, project: pr, pat: pa, type: nodeType } = Object.fromEntries(url.searchParams);
      if (!ou || !pr || !pa) { sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true; }
      ou = sanitizeAdoOrgUrl(ou);
      const typeStr = nodeType === 'iterations' ? 'iterations' : 'areas';
      const nodeUrl = `${ou}/${encodeURIComponent(pr)}/_apis/wit/classificationnodes/${typeStr}?$depth=6&api-version=7.1`;
      const r = await adoRequest(ou, pa, 'GET', nodeUrl, null);
      if (r.status >= 300) { sendJSON(res, { error: 'Erro ADO: ' + (r.body?.message || r.status) }, 500); return true; }
      function flatNodes(node, prefix) {
        const p = prefix ? prefix + '\\' + node.name : node.name;
        const out = [p];
        if (node.children) node.children.forEach(c => out.push(...flatNodes(c, p)));
        return out;
      }
      sendJSON(res, { paths: flatNodes(r.body) });
    } catch(e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/poll-all — varre todos os specs com ado-sync.json e retorna estado atual do ADO
  if (req.method === 'GET' && url.pathname === '/api/ado/poll-all') {
    try {
      const cfg = JSON.parse(await readFile(path.join(cwd, '.sdd', 'ado-config.json'), 'utf8').catch(() => '{}'));
      if (!cfg.pat) { sendJSON(res, { configured: false }); return true; }
      const { readdir } = await import('node:fs/promises');
      const specsDir = path.join(cwd, '.sdd', 'specs');
      const entries = await readdir(specsDir).catch(() => []);
      const results = [];
      for (const name of entries) {
        try {
          const syncData = JSON.parse(await readFile(path.join(specsDir, name, 'ado-sync.json'), 'utf8'));
          const { orgUrl: ou, project: pr, feature: feat } = syncData;
          if (!feat?.id) continue;
          const wiqlUrl = `${ou}/${encodeURIComponent(pr)}/_apis/wit/wiql?api-version=7.1`;
          const wiqlRes = await adoRequest(ou, cfg.pat, 'POST', wiqlUrl, {
            query: `SELECT [System.Id],[System.Title],[System.State] FROM WorkItems WHERE [System.WorkItemType]='User Story' AND [System.Parent]=${feat.id} ORDER BY [System.Id]`
          });
          const ids = (wiqlRes.body?.workItems || []).map(w => w.id);
          let stories = [];
          if (ids.length) {
            const batchRes = await adoRequest(ou, cfg.pat, 'POST', `${ou}/_apis/wit/workitemsbatch?api-version=7.1`, {
              ids, fields: ['System.Id','System.Title','System.State']
            });
            stories = (batchRes.body?.value || []).map(w => ({
              id: w.id, title: w.fields['System.Title'], state: w.fields['System.State']
            }));
          }
          results.push({ name, feature: feat.title, stories });
        } catch { /* spec sem sync ou ADO inacessível */ }
      }
      sendJSON(res, { configured: true, specs: results, timestamp: new Date().toISOString() });
    } catch(e) { sendJSON(res, { configured: false, error: e.message }); }
    return true;
  }

  // GET /api/ado/sync-status?specName=...
  if (req.method === 'GET' && url.pathname === '/api/ado/sync-status') {
    try {
      const specName = url.searchParams.get('specName');
      if (!specName) { sendJSON(res, { hasSync: false }); return true; }
      const data = await readFile(path.join(cwd, '.sdd', 'specs', specName, 'ado-sync.json'), 'utf8');
      sendJSON(res, { hasSync: true, syncData: JSON.parse(data) });
    } catch { sendJSON(res, { hasSync: false }); }
    return true;
  }

  // POST /api/ado/pull-changes — busca stories no ADO e compara com spec local
  if (req.method === 'POST' && url.pathname === '/api/ado/pull-changes') {
    try {
      const { specName, adoCreds } = JSON.parse(await readBody(req));
      const syncData = JSON.parse(await readFile(path.join(cwd, '.sdd', 'specs', specName, 'ado-sync.json'), 'utf8'));
      const { orgUrl: ou, project: pr } = syncData;
      const { pat } = adoCreds;
      const featureId = syncData.feature?.id;
      if (!featureId) { sendJSON(res, { error: 'Feature ID não encontrado no ado-sync.json' }, 400); return true; }
      // Buscar stories filhas da Feature via WIQL
      const wiqlUrl = `${ou}/${encodeURIComponent(pr)}/_apis/wit/wiql?api-version=7.1`;
      const wiqlRes = await adoRequest(ou, pat, 'POST', wiqlUrl, {
        query: `SELECT [System.Id],[System.Title],[Microsoft.VSTS.Common.AcceptanceCriteria] FROM WorkItems WHERE [System.WorkItemType]='User Story' AND [System.Parent]=${featureId} ORDER BY [System.Id]`
      });
      if (wiqlRes.status >= 300) { sendJSON(res, { error: 'Erro WIQL: ' + (wiqlRes.body?.message || wiqlRes.status) }, 500); return true; }
      const ids = (wiqlRes.body.workItems || []).map(w => w.id);
      let adoStories = [];
      if (ids.length) {
        const batchUrl = `${ou}/_apis/wit/workitemsbatch?api-version=7.1`;
        const batchRes = await adoRequest(ou, pat, 'POST', batchUrl, { ids, fields: ['System.Id','System.Title','Microsoft.VSTS.Common.AcceptanceCriteria','System.State'] });
        adoStories = (batchRes.body?.value || []).map(w => ({
          id: w.id, title: w.fields['System.Title'] || '', criteria: w.fields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '', state: w.fields['System.State'] || ''
        }));
      }
      const localStories = await parseRequirementsForAdo(specName, cwd);
      sendJSON(res, { adoStories, localStories, syncData });
    } catch(e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // GET /api/ado/fetch-work-item?id=...&orgUrl=...&project=...&pat=...
  if (req.method === 'GET' && url.pathname === '/api/ado/fetch-work-item') {
    try {
      const rawId = url.searchParams.get('id') || '';
      let orgUrl = (url.searchParams.get('orgUrl') || '').trim();
      const project = (url.searchParams.get('project') || '').trim();
      const pat = (url.searchParams.get('pat') || '').trim();
      if (!orgUrl || !pat) { sendJSON(res, { error: 'orgUrl e pat são obrigatórios. Configure as credenciais ADO no header.' }, 400); return true; }
      orgUrl = sanitizeAdoOrgUrl(orgUrl);

      // Accept full URL or plain ID
      const wiId = parseWorkItemId(rawId);
      if (!wiId) { sendJSON(res, { error: 'ID inválido. Use o número do Work Item ou a URL completa.' }, 400); return true; }

      const headers = {
        'Authorization': 'Basic ' + Buffer.from(':' + pat).toString('base64'),
        'Accept': 'application/json',
      };
      const fields = [
        'System.Id','System.Title','System.WorkItemType','System.State',
        'System.Description','Microsoft.VSTS.Common.AcceptanceCriteria',
        'System.AreaPath','System.IterationPath',
      ].join(',');
      const proj = project ? encodeURIComponent(project) + '/' : '';
      const wiUrl = `${orgUrl}/${proj}_apis/wit/workitems/${wiId}?fields=${encodeURIComponent(fields)}&api-version=7.0`;
      const wiRes = await fetch(wiUrl, { headers });
      if (!wiRes.ok) {
        const txt = await wiRes.text().catch(() => '');
        if (wiRes.status === 401 || wiRes.status === 403) { sendJSON(res, { error: 'Sem permissão — verifique o PAT e o projeto.' }, 400); return true; }
        if (wiRes.status === 404) { sendJSON(res, { error: `Work Item #${wiId} não encontrado no projeto.` }, 404); return true; }
        sendJSON(res, { error: `ADO retornou ${wiRes.status}: ${txt.slice(0, 200)}` }, 400); return true;
      }
      const wi = await wiRes.json();
      const f = wi.fields || {};

      // Fetch comments
      const commentsUrl = `${orgUrl}/${proj}_apis/wit/workItems/${wiId}/comments?api-version=7.1-preview.3`;
      const commentsRes = await fetch(commentsUrl, { headers }).catch(() => null);
      let comments = [];
      if (commentsRes?.ok) {
        const cd = await commentsRes.json().catch(() => ({}));
        comments = await Promise.all((cd.comments || []).map(async c => {
          const rawHtml = c.text || '';
          const text = rawHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
          // Extrair e descrever imagens embutidas no comentário
          const imgRe = /<img[^>]+src="([^"]+)"/gi;
          let imgMatch; const imgDescs = [];
          while ((imgMatch = imgRe.exec(rawHtml)) !== null) {
            const imgUrl = imgMatch[1].replace(/&amp;/g, '&');
            try {
              const ir = await fetch(imgUrl, { headers });
              if (ir.ok) {
                const buf = await ir.arrayBuffer();
                const mt = ir.headers.get('content-type') || 'image/png';
                imgDescs.push(await describeImageWithClaude(Buffer.from(buf).toString('base64'), mt));
              }
            } catch {}
          }
          return {
            author: c.createdBy?.displayName || 'Desconhecido',
            date: (c.createdDate || '').slice(0, 10),
            text: [text, ...imgDescs.map((d, i) => `[Imagem ${i + 1}: ${d}]`)].filter(Boolean).join('\n'),
            imageCount: imgDescs.length,
          };
        }));
        comments = comments.filter(c => c.text);
      }

      // Fetch attachments (via relations expand) — read all formats
      const relUrl = `${orgUrl}/${proj}_apis/wit/workitems/${wiId}?$expand=relations&api-version=7.0`;
      const relRes = await fetch(relUrl, { headers }).catch(() => null);
      let attachments = [];
      if (relRes?.ok) {
        const rd = await relRes.json().catch(() => ({}));
        const rawAttachments = (rd.relations || []).filter(r => r.rel === 'AttachedFile');
        const IMAGE_EXTS = new Set(['png','jpg','jpeg','gif','webp','bmp','tiff','tif']);
        const KNOWN_BINARY_EXTS = new Set(['ico','svg','mp4','mp3','wav','avi','mov','mkv','zip','7z','gz','tar','rar','br','exe','dll','so','dylib','bin','dat']);
        const OFFICE_EXTS = new Set(['docx','odt','xlsx','ods','pptx','odp']);
        const stripXml = s => s.replace(/<[^>]+>/g, ' ').replace(/&(?:[a-z]+|#\d+);/g, ' ').replace(/\s+/g, ' ').trim();
        const truncNote = (buf, content) => content + (buf.byteLength > 32768 ? `\n...(truncado — ${Math.round(buf.byteLength/1024)}KB total)` : '');

        attachments = await Promise.all(rawAttachments.map(async r => {
          const name = r.attributes?.name || r.url?.split('/').pop() || '';
          const ext = (name.split('.').pop() || '').toLowerCase();
          let content = null;
          try {
            const attachUrl = r.url + (r.url.includes('?') ? '&' : '?') + 'api-version=7.0';
            const ar = await fetch(attachUrl, { headers });
            if (!ar.ok) {
              content = `(HTTP ${ar.status} ao baixar anexo)`;
              return { name, url: r.url, content };
            }
            const buf = await ar.arrayBuffer();
            const sizeKB = Math.round(buf.byteLength / 1024);

            if (IMAGE_EXTS.has(ext)) {
              // Imagem — descrever via visão
              const b64 = Buffer.from(buf).toString('base64');
              const mt = (ext === 'jpg' || ext === 'jpeg') ? 'image/jpeg' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/png';
              content = `[Imagem: ${name}]\n${await describeImageWithClaude(b64, mt)}`;
            } else if (KNOWN_BINARY_EXTS.has(ext)) {
              content = `(${ext.toUpperCase()} — ${sizeKB}KB — arquivo binário, não legível)`;
            } else if (OFFICE_EXTS.has(ext)) {
              // ZIP-based Office formats — extract XML and strip tags
              const tmpFile = path.join(tmpdir(), 'att_' + randomUUID() + '.' + ext);
              try {
                await writeFile(tmpFile, Buffer.from(buf));
                let xml = '';
                if (ext === 'docx' || ext === 'odt') {
                  const target = ext === 'docx' ? 'word/document.xml' : 'content.xml';
                  xml = execSync(`unzip -p "${tmpFile}" "${target}" 2>/dev/null`, { timeout: 8000 }).toString();
                } else if (ext === 'xlsx' || ext === 'ods') {
                  try { xml = execSync(`unzip -p "${tmpFile}" "xl/sharedStrings.xml" 2>/dev/null`, { timeout: 8000 }).toString(); } catch {}
                  if (!xml.trim()) {
                    try { xml = execSync(`unzip -p "${tmpFile}" "xl/worksheets/sheet1.xml" 2>/dev/null`, { timeout: 8000 }).toString(); } catch {}
                  }
                  if (xml.trim()) {
                    // Also grab first sheet for cell values
                    try {
                      const s1 = execSync(`unzip -p "${tmpFile}" "xl/worksheets/sheet1.xml" 2>/dev/null`, { timeout: 8000 }).toString();
                      xml += '\n' + s1;
                    } catch {}
                  }
                } else if (ext === 'pptx' || ext === 'odp') {
                  const slideDir = ext === 'pptx' ? 'ppt/slides' : 'Presentation.xml';
                  try {
                    const listing = execSync(`unzip -Z1 "${tmpFile}" "${slideDir}/slide*.xml" 2>/dev/null`, { timeout: 5000 }).toString();
                    for (const sf of listing.trim().split('\n').filter(Boolean).slice(0, 50)) {
                      try { xml += execSync(`unzip -p "${tmpFile}" "${sf.trim()}" 2>/dev/null`, { timeout: 5000 }).toString() + '\n'; } catch {}
                    }
                  } catch {}
                }
                content = xml.trim()
                  ? truncNote(buf, stripXml(xml).slice(0, 32768))
                  : `(${ext.toUpperCase()} — ${sizeKB}KB — não foi possível extrair texto)`;
              } catch {
                content = `(${ext.toUpperCase()} — ${sizeKB}KB — erro ao ler)`;
              } finally {
                await rm(tmpFile).catch(() => {});
              }
            } else if (ext === 'pdf') {
              const tmpFile = path.join(tmpdir(), 'att_' + randomUUID() + '.pdf');
              try {
                await writeFile(tmpFile, Buffer.from(buf));
                let text = '';
                try { text = execSync(`pdftotext -l 20 "${tmpFile}" - 2>/dev/null`, { timeout: 10000 }).toString(); } catch {}
                content = text.trim()
                  ? truncNote(buf, text.trim().slice(0, 32768))
                  : `(PDF — ${sizeKB}KB — pdftotext não disponível ou PDF sem texto)`;
              } catch {
                content = `(PDF — ${sizeKB}KB — erro ao ler)`;
              } finally {
                await rm(tmpFile).catch(() => {});
              }
            } else if (ext === 'html' || ext === 'htm') {
              const raw = new TextDecoder('utf-8', { fatal: false }).decode(buf);
              // Mantém HTML estrutural (tabelas, formulários, botões, inputs, headings)
              // Remove apenas ruído: <script>, <style>, comentários e atributos style/on*
              const cleanedHtml = raw
                .replace(/<script[\s\S]*?<\/script>/gi, '')
                .replace(/<style[\s\S]*?<\/style>/gi, '')
                .replace(/<!--[\s\S]*?-->/g, '')
                .replace(/\s+style="[^"]*"/gi, '')
                .replace(/\s+on\w+="[^"]*"/gi, '')
                .replace(/\s+/g, ' ')
                .trim();
              // 64KB para HTML — protótipos complexos precisam de mais espaço
              content = truncNote(buf, cleanedHtml.slice(0, 65536));
            } else {
              // Try as plain text; fall back to binary note
              const bytes = new Uint8Array(buf.slice(0, 4000));
              const nonPrintable = bytes.filter(b => b < 9 || (b > 13 && b < 32) || b === 127).length;
              const isBinary = bytes.length > 0 && nonPrintable / bytes.length > 0.15;
              if (isBinary) {
                content = `(arquivo binário — ${name}, ${sizeKB}KB)`;
              } else {
                const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
                content = truncNote(buf, text.slice(0, 32768));
              }
            }
          } catch { /* skip unreadable */ }
          return { name, url: r.url, content };
        }));
      }

      // Buscar US filhas existentes
      let existingUs = [];
      try {
        const childWiqlRes = await fetch(`${orgUrl}/${proj}_apis/wit/wiql?api-version=7.1`, {
          method: 'POST',
          headers: { ...headers, 'Content-Type': 'application/json' },
          body: JSON.stringify({ query: `SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] IN ('User Story','Product Backlog Item') AND [System.Parent] = ${wiId} ORDER BY [System.Id]` })
        }).catch(() => null);
        if (childWiqlRes?.ok) {
          const childData = await childWiqlRes.json().catch(() => ({}));
          const childIds = (childData.workItems || []).map(w => w.id);
          if (childIds.length) {
            const batchRes = await fetch(`${orgUrl}/${proj}_apis/wit/workitemsbatch?api-version=7.1`, {
              method: 'POST',
              headers: { ...headers, 'Content-Type': 'application/json' },
              body: JSON.stringify({ ids: childIds.slice(0, 100), fields: ['System.Id', 'System.Title', 'System.State'] })
            }).catch(() => null);
            if (batchRes?.ok) {
              const bd = await batchRes.json().catch(() => ({}));
              existingUs = (bd.value || []).map(w => ({
                id: w.id,
                title: w.fields?.['System.Title'] || '',
                state: w.fields?.['System.State'] || ''
              }));
            }
          }
        }
      } catch {}

      sendJSON(res, {
        id: wi.id,
        title: f['System.Title'] || '',
        type: f['System.WorkItemType'] || 'Work Item',
        state: f['System.State'] || '',
        description: f['System.Description'] || '',
        acceptanceCriteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] || '',
        areaPath: f['System.AreaPath'] || '',
        iterationPath: f['System.IterationPath'] || '',
        comments,
        attachments,
        attachmentsMeta: attachments.map(a => ({ name: a.name, hasContent: !!a.content, contentLen: a.content?.length || 0 })),
        existingUs,
      });
    } catch (e) { sendJSON(res, { error: e.message }, 500); }
    return true;
  }

  // POST /api/ado/import-csv
  if (req.method === 'POST' && url.pathname === '/api/ado/import-csv') {
    try {
      const body = await readBody(req);
      let { orgUrl, project, pat, areaPath, iteration, items } = JSON.parse(body);
      if (!orgUrl || !project || !pat) {
        sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true;
      }
      orgUrl = sanitizeAdoOrgUrl(orgUrl);
      const created = [];
      const epicCache = {};

      for (const item of items) {
        const epicName = item['Épico'] || item['Epico'] || item['Epic'] || '';
        const title = item['Título'] || item['Titulo'] || item['Title'] || '';
        const description = [
          item['Story Description'] ? '<strong>Descrição:</strong><br>' + item['Story Description'] : '',
          item['User_Persona'] ? '<strong>Persona:</strong> ' + item['User_Persona'] : '',
          item['Desejo'] ? '<strong>Desejo:</strong> ' + item['Desejo'] : '',
          item['Valor_Negocio'] ? '<strong>Valor de Negócio:</strong> ' + item['Valor_Negocio'] : '',
          item['Componente'] ? '<strong>Componente:</strong> ' + item['Componente'] : '',
        ].filter(Boolean).join('<br><br>');
        const criteria = item['Criterios_Aceite'] || item['Criterios de Aceite'] || '';
        const tag = item['Tags'] || item['Tag'] || '';

        if (!title) continue;

        // Garante Epic
        let epicId = null;
        if (epicName) {
          if (epicCache[epicName]) {
            epicId = epicCache[epicName];
          } else {
            epicId = await adoFindEpic(orgUrl, project, pat, epicName);
            if (!epicId) {
              const er = await adoCreateWorkItem(orgUrl, project, pat, 'Epic', epicName, '', '', null, areaPath, tag, iteration);
              if (er.status < 300) {
                epicId = er.body.id;
                created.push({ type: 'Epic', title: epicName, id: epicId, url: er.body._links?.html?.href });
              }
            }
            epicCache[epicName] = epicId;
          }
        }

        const sr = await adoCreateWorkItem(orgUrl, project, pat, 'User Story', title, description, criteria, epicId, areaPath, tag, iteration);
        if (sr.status < 300) {
          created.push({ type: 'User Story', title, id: sr.body.id, url: sr.body._links?.html?.href });
        }
      }
      console.log('[ADO] import-csv → criados:', created.length);
      sendJSON(res, { created });
    } catch (e) {
      sendJSON(res, { error: e.message }, 500);
    }
    return true;
  }

  // POST /api/ado/list-stories
  if (req.method === 'POST' && url.pathname === '/api/ado/list-stories') {
    try {
      const body = await readBody(req);
      let { orgUrl, project, pat, areaPath } = JSON.parse(body);
      if (!orgUrl || !project || !pat) {
        sendJSON(res, { error: 'orgUrl, project e pat são obrigatórios' }, 400); return true;
      }
      orgUrl = sanitizeAdoOrgUrl(orgUrl);
      const auth = Buffer.from(':' + pat).toString('base64');
      const headers = { 'Authorization': 'Basic ' + auth, 'Content-Type': 'application/json', 'Accept': 'application/json' };

      // WIQL query to fetch User Stories
      let wiql = "SELECT [System.Id],[System.Title] FROM WorkItems WHERE [System.TeamProject] = '" + project + "' AND [System.WorkItemType] = 'User Story' AND [System.State] <> 'Removed'";
      if (areaPath) wiql += " AND [System.AreaPath] UNDER '" + areaPath + "'";
      wiql += ' ORDER BY [System.ChangedDate] DESC';

      const wiqlUrl = orgUrl + '/' + encodeURIComponent(project) + '/_apis/wit/wiql?api-version=7.0';
      const wiqlRes = await fetch(wiqlUrl, { method: 'POST', headers, body: JSON.stringify({ query: wiql }) });
      if (!wiqlRes.ok) {
        const txt = await wiqlRes.text();
        sendJSON(res, { error: 'ADO WIQL error ' + wiqlRes.status + ': ' + txt.slice(0, 200) }, 400); return true;
      }
      const wiqlData = await wiqlRes.json();
      const ids = (wiqlData.workItems || []).map(function(w) { return w.id; }).slice(0, 100);
      if (!ids.length) { sendJSON(res, { stories: [] }); return true; }

      // Batch fetch work item details
      const detailsUrl = orgUrl + '/' + encodeURIComponent(project) + '/_apis/wit/workitems?ids=' + ids.join(',') + '&fields=System.Id,System.Title,Microsoft.VSTS.Common.AcceptanceCriteria&api-version=7.0';
      const detailsRes = await fetch(detailsUrl, { method: 'GET', headers });
      if (!detailsRes.ok) {
        const txt = await detailsRes.text();
        sendJSON(res, { error: 'ADO details error ' + detailsRes.status + ': ' + txt.slice(0, 200) }, 400); return true;
      }
      const detailsData = await detailsRes.json();
      const stories = (detailsData.value || []).map(function(w) {
        return {
          id: w.id,
          title: (w.fields && w.fields['System.Title']) || '',
          acceptanceCriteria: (w.fields && w.fields['Microsoft.VSTS.Common.AcceptanceCriteria']) || ''
        };
      });
      sendJSON(res, { stories });
    } catch (e) {
      sendJSON(res, { error: e.message }, 500);
    }
    return true;
  }

  // POST /api/ado/epic-hierarchy — auto-discovery of Epics via WIQL across all ORGs
  if (req.method === 'POST' && url.pathname === '/api/ado/epic-hierarchy') {
    try {
      const body = JSON.parse(await readBody(req));
      const orgs = body.orgs; // [{orgUrl, pat, areaFilter?}]
      const globalAreaFilter = (body.areaFilter || '').trim();
      if (!Array.isArray(orgs) || orgs.length === 0) { sendJSON(res, { error: 'orgs é obrigatório' }, 400); return true; }

      res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
      const sse = (data) => { try { res.write('data: ' + JSON.stringify(data) + '\n\n'); } catch(_) {} };

      // Concurrency limiter — caps simultaneous ADO requests per org
      function createLimiter(maxConcurrent) {
        let running = 0;
        const queue = [];
        function tick() {
          if (running >= maxConcurrent || queue.length === 0) return;
          running++;
          const { fn, resolve, reject } = queue.shift();
          fn().then(r => { running--; resolve(r); tick(); }).catch(e => { running--; reject(e); tick(); });
        }
        return function limit(fn) {
          return new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); tick(); });
        };
      }

      // Retry wrapper — backs off on 429 (up to 3 attempts, 1.5s / 3s / 6s)
      async function adoReq(limit, base, pat, method, url, body) {
        for (let attempt = 0; attempt < 3; attempt++) {
          const r = await limit(() => adoRequest(base, pat, method, url, body));
          if (r.status !== 429) return r;
          await new Promise(res => setTimeout(res, (attempt + 1) * 1500));
        }
        return limit(() => adoRequest(base, pat, method, url, body));
      }

      function eaStripHtml(html) {
        if (!html) return '';
        return String(html).replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
      }
      function eaExtractQuarter(iterationPath, createdDate) {
        if (iterationPath) { const m = iterationPath.match(/Q([1-4])/i); if (m) return 'Q' + m[1]; }
        if (createdDate) { const mo = new Date(createdDate).getMonth(); return 'Q' + (Math.floor(mo / 3) + 1); }
        return 'unknown';
      }
      function eaNormalizeBase(rawOrg) {
        let orgPath = String(rawOrg).trim();
        if (orgPath.startsWith('https://') || orgPath.startsWith('http://')) {
          try { const _u = new URL(orgPath); orgPath = _u.pathname.split('/').filter(Boolean).slice(0, 2).join('/'); }
          catch { orgPath = orgPath.replace(/^https?:\/\/[^/]+\//, ''); }
        }
        return 'https://dev.azure.com/' + orgPath.split('/').map(s => encodeURIComponent(s.trim())).filter(Boolean).join('/');
      }
      const INTAKE_TAG_RE = /[A-Z]{2}-\d{2}-\d{2}-\d+/;

      async function processOrgEpics({ orgUrl, pat, areaFilter: orgAreaFilter }) {
        const areaFilter = orgAreaFilter !== undefined ? String(orgAreaFilter).trim() : globalAreaFilter;
        const base = eaNormalizeBase(orgUrl);
        const orgSlug = String(orgUrl).replace(/^https?:\/\/[^/]+\//, '').replace(/\/$/, '') || String(orgUrl).split('/').filter(Boolean).join('/');

        // Higher concurrency: fewer, larger requests
        const limit = createLimiter(12);
        const areaClause = areaFilter ? ` AND [System.AreaPath] UNDER '${areaFilter.replace(/'/g, "''")}'` : '';
        const srcArea = areaFilter ? ` AND [Source].[System.AreaPath] UNDER '${areaFilter.replace(/'/g, "''")}'` : '';

        sse({ type: 'org_searching', orgSlug, msg: `Buscando hierarquia via WorkItemLinks em ${orgSlug}...` });

        // Helper: run the 3 WIQLs scoped to a given URL base (org or project)
        async function runScopeWiqls(scopeBase) {
          const [ew, ef, fu] = await Promise.all([
            adoReq(limit, base, pat, 'POST', `${scopeBase}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType]='Epic'${areaClause} ORDER BY [System.Id]`,
              top: 2000
            }),
            adoReq(limit, base, pat, 'POST', `${scopeBase}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Epic'${srcArea} AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND [Target].[System.WorkItemType]='Feature' MODE (MustContain)`,
              top: 20000
            }),
            adoReq(limit, base, pat, 'POST', `${scopeBase}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Feature'${srcArea} AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND ([Target].[System.WorkItemType]='User Story' OR [Target].[System.WorkItemType]='Product Backlog Item') MODE (MustContain)`,
              top: 100000
            })
          ]);
          return { ew, ef, fu };
        }

        // ── FASE 1: tenta org-level; fallback per-project se org muito grande ──
        let { ew: epicWiql, ef: epicFeatLinks, fu: featUsLinks } = await runScopeWiqls(base);

        // Distingue 400 por "org muito grande" vs "área inválida"
        const isSizeLimit = (r) => r.status === 400 && (r.body?.message || '').includes('VS402337');
        const isAreaError = (r) => r.status === 400 && (r.body?.message || '').includes('TF51011');

        if (isAreaError(epicWiql) || isAreaError(epicFeatLinks) || isAreaError(featUsLinks)) {
          // Área não existe nesta org — refaz sem filtro de área
          sse({ type: 'org_searching', orgSlug, msg: `Área "${areaFilter}" não encontrada em ${orgSlug} — buscando sem filtro de área...` });
          const [ew2, ef2, fu2] = await Promise.all([
            adoReq(limit, base, pat, 'POST', `${base}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType]='Epic' ORDER BY [System.Id]`, top: 2000
            }),
            adoReq(limit, base, pat, 'POST', `${base}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Epic' AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND [Target].[System.WorkItemType]='Feature' MODE (MustContain)`, top: 20000
            }),
            adoReq(limit, base, pat, 'POST', `${base}/_apis/wit/wiql?api-version=7.1`, {
              query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Feature' AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND ([Target].[System.WorkItemType]='User Story' OR [Target].[System.WorkItemType]='Product Backlog Item') MODE (MustContain)`, top: 100000
            })
          ]);
          epicWiql = ew2; epicFeatLinks = ef2; featUsLinks = fu2;
        }

        if (isSizeLimit(epicWiql) || isSizeLimit(epicFeatLinks) || isSizeLimit(featUsLinks)) {
          // Org tem mais de 20k itens — enumera projetos e roda per-project
          sse({ type: 'org_searching', orgSlug, msg: `Org muito grande (>20k itens) — enumerando projetos de ${orgSlug}...` });

          const projects = [];
          let skip = 0;
          while (true) {
            const pr = await adoReq(limit, base, pat, 'GET', `${base}/_apis/projects?api-version=7.1&$top=500&$skip=${skip}`, null);
            if (pr.status !== 200 || !Array.isArray(pr.body?.value) || pr.body.value.length === 0) break;
            projects.push(...pr.body.value.map(p => p.name));
            if (pr.body.value.length < 500) break;
            skip += 500;
          }

          // Se areaFilter já tem prefixo de projeto (ex: "Proj\Time"), só roda esse projeto
          const relevantProjects = (areaFilter && areaFilter.includes('\\'))
            ? projects.filter(p => areaFilter.startsWith(p + '\\'))
            : projects;

          sse({ type: 'org_searching', orgSlug, msg: `${relevantProjects.length} projeto(s) relevante(s) — executando WIQLs em paralelo...` });

          const scopeResults = await Promise.all(relevantProjects.map(async (proj) => {
            const fullArea = areaFilter
              ? (areaFilter.startsWith(proj + '\\') ? areaFilter : proj + '\\' + areaFilter)
              : '';
            const projArea = fullArea ? ` AND [System.AreaPath] UNDER '${fullArea.replace(/'/g, "''")}'` : '';
            const projSrcArea = fullArea ? ` AND [Source].[System.AreaPath] UNDER '${fullArea.replace(/'/g, "''")}'` : '';
            const projBase = `${base}/${encodeURIComponent(proj)}`;
            const [ew, ef, fu] = await Promise.all([
              adoReq(limit, base, pat, 'POST', `${projBase}/_apis/wit/wiql?api-version=7.1`, {
                query: `SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType]='Epic'${projArea} ORDER BY [System.Id]`, top: 2000
              }),
              adoReq(limit, base, pat, 'POST', `${projBase}/_apis/wit/wiql?api-version=7.1`, {
                query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Epic'${projSrcArea} AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND [Target].[System.WorkItemType]='Feature' MODE (MustContain)`, top: 20000
              }),
              adoReq(limit, base, pat, 'POST', `${projBase}/_apis/wit/wiql?api-version=7.1`, {
                query: `SELECT [Source].[System.Id],[Target].[System.Id] FROM WorkItemLinks WHERE [Source].[System.WorkItemType]='Feature'${projSrcArea} AND [System.Links.LinkType]='System.LinkTypes.Hierarchy-Forward' AND ([Target].[System.WorkItemType]='User Story' OR [Target].[System.WorkItemType]='Product Backlog Item') MODE (MustContain)`, top: 100000
              })
            ]);
            return { ew, ef, fu };
          }));

          const allEpicItems = [], allEpicFeatRels = [], allFeatUsRels = [];
          for (const sr of scopeResults) {
            if (sr.ew.status === 200 && Array.isArray(sr.ew.body?.workItems)) allEpicItems.push(...sr.ew.body.workItems);
            if (sr.ef.body?.workItemRelations) allEpicFeatRels.push(...sr.ef.body.workItemRelations);
            if (sr.fu.body?.workItemRelations) allFeatUsRels.push(...sr.fu.body.workItemRelations);
          }
          epicWiql      = { status: 200, body: { workItems: allEpicItems } };
          epicFeatLinks = { status: 200, body: { workItemRelations: allEpicFeatRels } };
          featUsLinks   = { status: 200, body: { workItemRelations: allFeatUsRels } };
        }

        if (epicWiql.status !== 200 || !Array.isArray(epicWiql.body?.workItems)) {
          sse({ type: 'org_error', orgSlug, msg: 'Erro ao buscar Epics: HTTP ' + epicWiql.status + ' — ' + (epicWiql.body?.message || '').slice(0, 120) });
          return [];
        }

        const epicIds = epicWiql.body.workItems.map(w => w.id);
        if (epicIds.length === 0) { sse({ type: 'org_no_epics', orgSlug }); return []; }
        const epicSet = new Set(epicIds);

        // Montar mapas de relações a partir dos links
        const featByEpicIds = new Map(); // epicId → Set<featId>
        const featSet = new Set();
        for (const rel of (epicFeatLinks.body?.workItemRelations || [])) {
          if (!rel.source || !rel.target) continue;
          const eid = rel.source.id, fid = rel.target.id;
          if (!epicSet.has(eid)) continue;
          if (!featByEpicIds.has(eid)) featByEpicIds.set(eid, new Set());
          featByEpicIds.get(eid).add(fid);
          featSet.add(fid);
        }

        const usByFeatIds = new Map(); // featId → Set<usId>
        const usSet = new Set();
        for (const rel of (featUsLinks.body?.workItemRelations || [])) {
          if (!rel.source || !rel.target) continue;
          const fid = rel.source.id, uid = rel.target.id;
          if (!featSet.has(fid)) continue;
          if (!usByFeatIds.has(fid)) usByFeatIds.set(fid, new Set());
          usByFeatIds.get(fid).add(uid);
          usSet.add(uid);
        }

        const featIds = [...featSet];
        const usIds   = [...usSet];

        sse({ type: 'org_epics_found', orgSlug, count: epicIds.length, msg: `${epicIds.length} Epics · ${featIds.length} Features · ${usIds.length} USs — baixando detalhes...` });

        // ── FASE 2: batch-fetch de todos os detalhes em paralelo ──────────────
        const batchFetch = async (ids, fields) => {
          if (ids.length === 0) return [];
          const proms = [];
          for (let i = 0; i < ids.length; i += 200)
            proms.push(adoReq(limit, base, pat, 'POST', `${base}/_apis/wit/workitemsbatch?api-version=7.1`, { ids: ids.slice(i, i + 200), fields }));
          const out = [];
          for (const br of await Promise.all(proms))
            if (br.status === 200 && Array.isArray(br.body?.value)) out.push(...br.body.value);
          return out;
        };

        // Filtrar USs por data 2026 antes do batch (evita buscar milhares de USs antigas)
        const usIdsFiltered = usIds.length <= 5000 ? usIds : usIds.slice(0, 20000);

        const [epicDetails, featDetails, usDetails] = await Promise.all([
          batchFetch(epicIds, ['System.Id','System.Title','System.Tags','System.Description','System.State']),
          batchFetch(featIds, ['System.Id','System.Title','System.State','System.Description','System.AreaPath']),
          batchFetch(usIdsFiltered, ['System.Id','System.Title','System.State','System.AreaPath','System.IterationPath','System.Description','Microsoft.VSTS.Common.AcceptanceCriteria','System.CreatedDate','System.ChangedDate'])
        ]);

        sse({ type: 'org_searching', orgSlug, msg: `Detalhes baixados — cruzando hierarquia em memória...` });

        // ── FASE 3: índices para lookup rápido ───────────────────────────────
        const featMap = new Map(featDetails.map(f => [f.id, f]));
        const usMap   = new Map(usDetails.map(u => [u.id, u]));

        // Features agrupadas por Epic (usando o mapa de links)
        const featByEpic = new Map();
        for (const [eid, fids] of featByEpicIds) {
          featByEpic.set(eid, [...fids].map(fid => featMap.get(fid)).filter(Boolean));
        }

        // USs agrupadas por Feature (usando o mapa de links)
        const usByFeat = new Map();
        for (const [fid, uids] of usByFeatIds) {
          usByFeat.set(fid, [...uids].map(uid => usMap.get(uid)).filter(Boolean));
        }

        // ── FASE 4: montar resultados ─────────────────────────────────────────
        const results = epicDetails.map((epicRaw) => {
          const ef = epicRaw.fields || {};
          const epicTags = (ef['System.Tags'] || '').split(';').map(t => t.trim()).filter(Boolean);
          const hasIntakeTag = epicTags.some(t => INTAKE_TAG_RE.test(t));
          const intakeTag = epicTags.find(t => INTAKE_TAG_RE.test(t)) || null;
          const epicMeta = { id: epicRaw.id, title: ef['System.Title'] || '', state: ef['System.State'] || '', tags: epicTags, hasIntakeTag, intakeTag, orgSlug };
          sse({ type: 'epic_found', epicId: epicRaw.id, ...epicMeta });

          const features = featByEpic.get(epicRaw.id) || [];
          let featDone = 0;
          const featureResults = features.map((feat) => {
            const ff = feat.fields || {};
            const fid = feat.id;
            const usForFeat = usByFeat.get(fid) || [];

            const us2026 = usForFeat.filter(us => {
              const uf = us.fields || {};
              const created = uf['System.CreatedDate'] ? new Date(uf['System.CreatedDate']).getFullYear() : 0;
              const changed = uf['System.ChangedDate'] ? new Date(uf['System.ChangedDate']).getFullYear() : 0;
              return created === 2026 || changed === 2026;
            });

            const usByQuarter = { Q1: 0, Q2: 0, Q3: 0, Q4: 0, unknown: 0 };
            const invalidUs = [];
            let usValidCount = 0;
            for (const us of us2026) {
              const uf = us.fields || {};
              const q = eaExtractQuarter(uf['System.IterationPath'], uf['System.CreatedDate']);
              usByQuarter[q] = (usByQuarter[q] || 0) + 1;
              const descOk = eaStripHtml(uf['System.Description']).length >= 10;
              const acOk   = eaStripHtml(uf['Microsoft.VSTS.Common.AcceptanceCriteria']).length >= 10;
              if (descOk && acOk) usValidCount++;
              else invalidUs.push({ id: us.id, title: uf['System.Title'] || '', descOk, acOk });
            }

            sse({ type: 'feature_done', epicId: epicRaw.id, featureId: fid, featureTitle: ff['System.Title'] || '', us2026: us2026.length, done: ++featDone, total: features.length });
            return {
              id: fid, title: ff['System.Title'] || '', state: ff['System.State'] || '',
              parentOk: featByEpicIds.get(epicRaw.id)?.has(fid) ?? false,
              descOk: eaStripHtml(ff['System.Description']).length >= 10,
              usByQuarter, usTotal: usForFeat.length, us2026: us2026.length,
              usValid: usValidCount, usInvalid: invalidUs.length, invalidUs
            };
          });

          const totalUs2026  = featureResults.reduce((s, f) => s + f.us2026, 0);
          const totalInvalid = featureResults.reduce((s, f) => s + f.usInvalid, 0);
          const allInvalidUs = featureResults.flatMap(f => f.invalidUs.map(u => ({ ...u, featureId: f.id, featureTitle: f.title })));
          const result = { epic: epicMeta, features: featureResults, totalUs2026, totalInvalid, invalidUs: allInvalidUs };
          sse({ type: 'epic_done', epicId: epicRaw.id, result });
          return result;
        });

        sse({ type: 'org_done', orgSlug, count: results.length });
        return results;
      }

      // All ORGs in parallel — each has its own independent limiter
      const orgResults = await Promise.all(orgs.map(org => processOrgEpics(org)));
      const allResults = orgResults.flat();

      // Auto-save results to disk so data is never lost if the browser freezes
      try {
        const _scanFile = path.join(homedir(), `sdd-epic-scan-${new Date().toISOString().replace(/[:.]/g,'-').slice(0,19)}.json`);
        await writeFile(_scanFile, JSON.stringify(allResults, null, 2), 'utf8');
        sse({ type: 'scan_saved', file: _scanFile });
      } catch(_) {}

      sse({ type: 'all_done', epics: allResults });
      res.end();
    } catch (e) { try { sse({ type: 'error', msg: e.message || 'Erro interno' }); res.end(); } catch(_) {} }
    return true;
  }

  return false;
}
