// ms-learn.ts — Microsoft Learn search & local cache tools
//
// Provides 4 tools:
//   SearchMicrosoftLearn        - online search + cache hits merged
//   FetchMicrosoftLearn         - fetch a page, convert to markdown, cache
//   SearchMicrosoftLearnCache   - FTS5 query over cached pages only (offline)
//   RefreshMicrosoftLearnCache  - force re-fetch of a cached URL
//
// Cache:
//   data/ms-learn-cache/pages.sqlite (FTS5 with external-content triggers)
//
// HTML→Markdown is an inline minimal converter targeted at Learn page
// structure. It is not a general-purpose converter; if Learn changes
// markup conventions we adjust here.

import { mkdirSync, existsSync } from 'fs';
import { join, resolve } from 'path';
import Database from 'better-sqlite3';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { logger } from '../../logger.js';

// --- Cache DB -----------------------------------------------------------

const CACHE_DIR = resolve(process.cwd(), 'data', 'ms-learn-cache');
const DB_PATH = join(CACHE_DIR, 'pages.sqlite');

let _db: Database.Database | null = null;

function getDb(): Database.Database {
  if (_db) return _db;
  if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
  const db = new Database(DB_PATH);
  db.pragma('journal_mode = WAL');
  db.exec(`
    CREATE TABLE IF NOT EXISTS pages (
      rowid INTEGER PRIMARY KEY,
      url TEXT UNIQUE NOT NULL,
      title TEXT NOT NULL,
      locale TEXT NOT NULL,
      content TEXT NOT NULL,
      fetched_at INTEGER NOT NULL
    );

    CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
      title, content,
      content='pages',
      content_rowid='rowid',
      tokenize='unicode61 remove_diacritics 2'
    );

    CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
      INSERT INTO pages_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
    END;
    CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
      INSERT INTO pages_fts(pages_fts, rowid, title, content) VALUES('delete', old.rowid, old.title, old.content);
    END;
    CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
      INSERT INTO pages_fts(pages_fts, rowid, title, content) VALUES('delete', old.rowid, old.title, old.content);
      INSERT INTO pages_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
    END;
  `);
  _db = db;
  return db;
}

interface CachedPage {
  url: string;
  title: string;
  locale: string;
  content: string;
  fetched_at: number;
}

function getCached(url: string): CachedPage | null {
  const row = getDb().prepare('SELECT url, title, locale, content, fetched_at FROM pages WHERE url = ?').get(url);
  return (row as CachedPage | undefined) ?? null;
}

function upsertPage(page: CachedPage): void {
  getDb().prepare(`
    INSERT INTO pages (url, title, locale, content, fetched_at)
    VALUES (@url, @title, @locale, @content, @fetched_at)
    ON CONFLICT(url) DO UPDATE SET
      title = excluded.title,
      locale = excluded.locale,
      content = excluded.content,
      fetched_at = excluded.fetched_at
  `).run(page);
}

interface FtsHit {
  url: string;
  title: string;
  locale: string;
  snippet: string;
  fetched_at: number;
}

function searchFts(query: string, limit: number): FtsHit[] {
  // Escape FTS5 query: wrap each whitespace-separated term in quotes to avoid
  // operator interpretation. Strip embedded double quotes.
  const sanitized = query
    .split(/\s+/)
    .filter(Boolean)
    .map((t) => `"${t.replace(/"/g, '')}"`)
    .join(' ');
  if (!sanitized) return [];
  try {
    const rows = getDb().prepare(`
      SELECT p.url, p.title, p.locale, p.fetched_at,
             snippet(pages_fts, 1, '<mark>', '</mark>', ' … ', 32) AS snippet
      FROM pages_fts
      JOIN pages p ON p.rowid = pages_fts.rowid
      WHERE pages_fts MATCH ?
      ORDER BY rank
      LIMIT ?
    `).all(sanitized, limit);
    return rows as FtsHit[];
  } catch (err) {
    logger.warn(`[ms-learn] FTS5 query failed: ${(err as Error).message}`);
    return [];
  }
}

// --- HTML → Markdown ----------------------------------------------------

function decodeEntities(s: string): string {
  return s
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
    .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)));
}

function stripTags(s: string): string {
  return s.replace(/<[^>]+>/g, '');
}

function extractTitle(html: string): string {
  const candidates: string[] = [];
  const og = html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i);
  if (og?.[1]) candidates.push(decodeEntities(og[1]));
  const t = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
  if (t?.[1]) candidates.push(decodeEntities(stripTags(t[1])).replace(/\s*\|\s*Microsoft Learn\s*$/i, ''));
  const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
  if (h1?.[1]) candidates.push(decodeEntities(stripTags(h1[1])));

  for (const raw of candidates) {
    const cleaned = raw.trim();
    if (!cleaned) continue;
    // Learn often produces titles like "Foo - Foo" (page title ⊃ section title).
    // Collapse repeated halves.
    const halves = cleaned.split(/\s+-\s+/);
    if (halves.length === 2 && halves[0]!.trim() === halves[1]!.trim()) return halves[0]!.trim();
    return cleaned;
  }
  return '(untitled)';
}

/**
 * Convert a Learn page HTML to a search-friendly markdown body.
 * Targets the structure of learn.microsoft.com articles: <main> wrapping
 * an <article>, with headings, paragraphs, fenced code blocks, lists,
 * and links. Anything outside <main>/<article> (chrome, nav, footer) is
 * dropped. Not a general-purpose converter.
 */
function htmlToMarkdown(html: string): string {
  // Prefer <article> (Learn wraps the doc body in one). Fall back to <main>,
  // then to the full document. <main> on Learn contains a lot of chrome
  // (TOC, action buttons, breadcrumbs) that <article> excludes.
  const articleMatch = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
  const mainMatch = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
  let content = articleMatch?.[1] || mainMatch?.[1] || html;

  // Strip non-content blocks before structural conversion. Order matters:
  // remove containers before their inline elements get rewritten below.
  content = content
    .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
    .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
    .replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '')
    .replace(/<svg\b[^>]*>[\s\S]*?<\/svg>/gi, '')
    .replace(/<button\b[^>]*>[\s\S]*?<\/button>/gi, '')
    .replace(/<form\b[^>]*>[\s\S]*?<\/form>/gi, '')
    .replace(/<nav\b[^>]*>[\s\S]*?<\/nav>/gi, '')
    .replace(/<aside\b[^>]*>[\s\S]*?<\/aside>/gi, '')
    .replace(/<header\b[^>]*>[\s\S]*?<\/header>/gi, '')
    .replace(/<footer\b[^>]*>[\s\S]*?<\/footer>/gi, '')
    // Hidden / decorative elements.
    .replace(/<[^>]+\baria-hidden="true"[^>]*>[\s\S]*?<\/[a-zA-Z0-9]+>/gi, '')
    .replace(/<[^>]+\bhidden\b[^>]*>[\s\S]*?<\/[a-zA-Z0-9]+>/gi, '');

  // Code blocks first (before generic <code> / tag stripping).
  content = content.replace(
    /<pre\b[^>]*>\s*<code\b[^>]*class="[^"]*lang-([^"\s]+)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/gi,
    (_, lang, code) => '\n\n```' + lang + '\n' + decodeEntities(stripTags(code)).trimEnd() + '\n```\n\n',
  );
  content = content.replace(
    /<pre\b[^>]*>([\s\S]*?)<\/pre>/gi,
    (_, code) => '\n\n```\n' + decodeEntities(stripTags(code)).trimEnd() + '\n```\n\n',
  );
  content = content.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');

  // Headings.
  for (const level of [1, 2, 3, 4, 5, 6]) {
    const hashes = '#'.repeat(level);
    const re = new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)<\\/h${level}>`, 'gi');
    content = content.replace(re, (_, inner) => `\n\n${hashes} ${stripTags(inner).trim()}\n\n`);
  }

  // Lists.
  content = content
    .replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_, inner) => `- ${stripTags(inner).trim()}\n`)
    .replace(/<\/?(ul|ol)\b[^>]*>/gi, '\n');

  // Paragraphs / breaks.
  content = content
    .replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, '\n\n$1\n\n')
    .replace(/<br\s*\/?>/gi, '\n');

  // Tables: collapse rows to pipe-delimited lines (lossy but searchable).
  content = content.replace(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi, (_, row) => {
    const cells = [...(row as string).matchAll(/<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)]
      .map((m) => stripTags(m[1] ?? '').trim().replace(/\s+/g, ' '));
    return cells.length ? `| ${cells.join(' | ')} |\n` : '';
  });
  content = content.replace(/<\/?(table|thead|tbody|tfoot)\b[^>]*>/gi, '\n');

  // Inline emphasis & links.
  content = content
    .replace(/<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => `[${stripTags(text).trim()}](${href})`)
    .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**')
    .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');

  // Drop remaining tags and decode entities.
  content = stripTags(content);
  content = decodeEntities(content);

  // Normalize whitespace.
  content = content.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();

  return content;
}

// --- Microsoft Learn search API ----------------------------------------

interface LearnSearchResult {
  title: string;
  url: string;
  description: string;
}

async function callLearnSearchApi(
  query: string,
  locale: string,
  products: string[] | undefined,
  top: number,
  abortSignal: AbortSignal | undefined,
): Promise<LearnSearchResult[]> {
  const params = new URLSearchParams({
    search: query,
    locale,
    '$top': String(top),
    expandScope: 'true',
    partnerId: 'LearnSite',
  });
  if (products && products.length > 0) {
    params.set('products', products.join(','));
  }
  const url = `https://learn.microsoft.com/api/search?${params.toString()}`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 15000);
  const onExternalAbort = () => ac.abort();
  if (abortSignal) abortSignal.addEventListener('abort', onExternalAbort, { once: true });
  try {
    const resp = await fetch(url, {
      signal: ac.signal,
      headers: { 'User-Agent': 'maestro/ms-learn' },
    });
    if (!resp.ok) {
      throw new Error(`Learn search API returned HTTP ${resp.status}`);
    }
    const data = await resp.json() as { results?: Array<{ title?: string; url?: string; description?: string }> };
    const results = Array.isArray(data.results) ? data.results : [];
    return results
      .filter((r) => typeof r.url === 'string' && typeof r.title === 'string')
      .map((r) => ({
        title: String(r.title),
        url: String(r.url),
        description: typeof r.description === 'string' ? r.description : '',
      }));
  } finally {
    clearTimeout(timer);
    if (abortSignal) abortSignal.removeEventListener('abort', onExternalAbort);
  }
}

// --- Page fetch ---------------------------------------------------------

function deriveLocaleFromUrl(url: string): string {
  const m = url.match(/^https?:\/\/learn\.microsoft\.com\/([a-z]{2}-[a-z]{2})\//i);
  return m?.[1]?.toLowerCase() ?? 'en-us';
}

function canonicalizeUrl(rawUrl: string): string {
  try {
    const u = new URL(rawUrl);
    // Drop tracking / view-control query params; keep nothing by default.
    // Learn pages are addressed entirely by path.
    u.search = '';
    u.hash = '';
    return u.toString();
  } catch {
    return rawUrl;
  }
}

async function fetchAndCachePage(
  rawUrl: string,
  abortSignal: AbortSignal | undefined,
): Promise<CachedPage> {
  const url = canonicalizeUrl(rawUrl);
  if (!/^https?:\/\/learn\.microsoft\.com\//i.test(url)) {
    throw new Error(`URL is not on learn.microsoft.com: ${url}`);
  }
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 30000);
  const onExternalAbort = () => ac.abort();
  if (abortSignal) abortSignal.addEventListener('abort', onExternalAbort, { once: true });
  try {
    const resp = await fetch(url, {
      signal: ac.signal,
      headers: {
        'User-Agent': 'maestro/ms-learn',
        'Accept': 'text/html,application/xhtml+xml',
      },
      redirect: 'follow',
    });
    if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
    const html = await resp.text();
    const title = extractTitle(html);
    const content = htmlToMarkdown(html);
    const page: CachedPage = {
      url,
      title,
      locale: deriveLocaleFromUrl(url),
      content,
      fetched_at: Math.floor(Date.now() / 1000),
    };
    upsertPage(page);
    logger.info(`[ms-learn] cached url=${url} title="${title.slice(0, 80)}" bytes=${content.length}`);
    return page;
  } finally {
    clearTimeout(timer);
    if (abortSignal) abortSignal.removeEventListener('abort', onExternalAbort);
  }
}

// --- Tool definitions ---------------------------------------------------

const SEARCH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'SearchMicrosoftLearn',
    description: 'Microsoft Learn を検索し、オンライン結果とローカルキャッシュを統合して返す。詳細は ReadToolDoc({ name: "SearchMicrosoftLearn" })。',
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '検索クエリ (自然言語キーワード)' },
        locale: { type: 'string', description: 'ロケール (例: en-us, ja-jp)。省略時は en-us' },
        products: {
          type: 'array',
          items: { type: 'string' },
          description: '製品スコープで絞り込む (例: ["azure"], ["dotnet"])。省略時は Learn 全体',
        },
        top: { type: 'integer', description: '取得件数 (デフォルト 10、最大 25)' },
      },
      required: ['query'],
    },
  },
};

const FETCH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'FetchMicrosoftLearn',
    description: 'Microsoft Learn のページを取得し Markdown 化してローカルキャッシュに保存する (魚拓)。詳細は ReadToolDoc({ name: "FetchMicrosoftLearn" })。',
    parameters: {
      type: 'object',
      properties: {
        url: { type: 'string', description: 'learn.microsoft.com の URL (path に locale を含むこと)' },
      },
      required: ['url'],
    },
  },
};

const SEARCH_CACHE_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'SearchMicrosoftLearnCache',
    description: 'ローカルキャッシュに保存済みの Microsoft Learn ページのみを全文検索する (オフライン)。詳細は ReadToolDoc({ name: "SearchMicrosoftLearnCache" })。',
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '検索クエリ (FTS5)。スペース区切りで AND' },
        top: { type: 'integer', description: '取得件数 (デフォルト 10、最大 25)' },
      },
      required: ['query'],
    },
  },
};

const REFRESH_DEF: ToolDef = {
  type: 'function',
  function: {
    name: 'RefreshMicrosoftLearnCache',
    description: 'キャッシュ済み Microsoft Learn ページを強制再取得して上書きする。詳細は ReadToolDoc({ name: "RefreshMicrosoftLearnCache" })。',
    parameters: {
      type: 'object',
      properties: {
        url: { type: 'string', description: '再取得する learn.microsoft.com の URL' },
      },
      required: ['url'],
    },
  },
};

export const TOOL_DEFS: Record<string, ToolDef> = {
  SearchMicrosoftLearn: SEARCH_DEF,
  FetchMicrosoftLearn: FETCH_DEF,
  SearchMicrosoftLearnCache: SEARCH_CACHE_DEF,
  RefreshMicrosoftLearnCache: REFRESH_DEF,
};

// --- Tool execution -----------------------------------------------------

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  if (name === 'SearchMicrosoftLearn') return executeSearch(input, ctx);
  if (name === 'FetchMicrosoftLearn') return executeFetch(input, ctx);
  if (name === 'SearchMicrosoftLearnCache') return executeSearchCache(input);
  if (name === 'RefreshMicrosoftLearnCache') return executeRefresh(input, ctx);
  return null;
}

function clampTop(raw: unknown, def: number, max: number): number {
  const n = typeof raw === 'number' ? raw : Number(raw);
  if (!Number.isFinite(n) || n <= 0) return def;
  return Math.min(Math.floor(n), max);
}

async function executeSearch(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const query = String(input['query'] ?? '').trim();
  if (!query) return { output: 'query is required', isError: true };
  const locale = typeof input['locale'] === 'string' && input['locale'] ? String(input['locale']) : 'en-us';
  const products = Array.isArray(input['products']) ? (input['products'] as unknown[]).map(String).filter(Boolean) : undefined;
  const top = clampTop(input['top'], 10, 25);

  const lines: string[] = [];
  let onlineCount = 0;
  let onlineError: string | null = null;

  try {
    const online = await callLearnSearchApi(query, locale, products, top, ctx.abortSignal);
    onlineCount = online.length;
    if (online.length > 0) {
      lines.push(`## Online results (${online.length})`);
      for (const r of online) {
        const url = canonicalizeUrl(r.url);
        const cached = getCached(url) !== null;
        lines.push(`- [${r.title}](${url})${cached ? ' [cached]' : ''}`);
        if (r.description) lines.push(`  ${r.description}`);
      }
      lines.push('');
    }
  } catch (err) {
    onlineError = (err as Error).message;
    logger.warn(`[ms-learn] online search failed: ${onlineError}`);
  }

  const ftsHits = searchFts(query, top);
  if (ftsHits.length > 0) {
    lines.push(`## Cache hits (${ftsHits.length})`);
    for (const h of ftsHits) {
      lines.push(`- [${h.title}](${h.url})`);
      if (h.snippet) lines.push(`  ${h.snippet.replace(/\s+/g, ' ').trim()}`);
    }
    lines.push('');
  }

  if (onlineError && ftsHits.length === 0 && onlineCount === 0) {
    return { output: `Search failed (online: ${onlineError}; no cache hits)`, isError: true };
  }

  if (lines.length === 0) {
    return { output: `No results for "${query}" (locale=${locale})`, isError: false };
  }

  if (onlineError) {
    lines.push(`(online search failed: ${onlineError})`);
  }

  logger.info(`[ms-learn] search query="${query.slice(0, 80)}" locale=${locale} online=${onlineCount} cache=${ftsHits.length}`);
  return { output: lines.join('\n').trim(), isError: false };
}

async function executeFetch(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const rawUrl = String(input['url'] ?? '').trim();
  if (!rawUrl) return { output: 'url is required', isError: true };
  const url = canonicalizeUrl(rawUrl);
  const cached = getCached(url);
  if (cached) {
    const ageHours = Math.floor((Date.now() / 1000 - cached.fetched_at) / 3600);
    return {
      output: `Cached (age=${ageHours}h, ${cached.content.length} bytes)\n\n# ${cached.title}\n\n${cached.content}`,
      isError: false,
    };
  }
  try {
    const page = await fetchAndCachePage(url, ctx.abortSignal);
    return {
      output: `Fetched and cached (${page.content.length} bytes)\n\n# ${page.title}\n\n${page.content}`,
      isError: false,
    };
  } catch (err) {
    return { output: `Fetch failed: ${(err as Error).message}`, isError: true };
  }
}

function executeSearchCache(input: Record<string, unknown>): ToolResult {
  const query = String(input['query'] ?? '').trim();
  if (!query) return { output: 'query is required', isError: true };
  const top = clampTop(input['top'], 10, 25);
  const hits = searchFts(query, top);
  if (hits.length === 0) {
    return { output: `No cache hits for "${query}"`, isError: false };
  }
  const lines: string[] = [`Cache hits (${hits.length}):`];
  for (const h of hits) {
    lines.push(`- [${h.title}](${h.url})`);
    if (h.snippet) lines.push(`  ${h.snippet.replace(/\s+/g, ' ').trim()}`);
  }
  return { output: lines.join('\n'), isError: false };
}

async function executeRefresh(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
  const rawUrl = String(input['url'] ?? '').trim();
  if (!rawUrl) return { output: 'url is required', isError: true };
  const url = canonicalizeUrl(rawUrl);
  try {
    const page = await fetchAndCachePage(url, ctx.abortSignal);
    return { output: `Refreshed: ${url} (${page.content.length} bytes)`, isError: false };
  } catch (err) {
    return { output: `Refresh failed: ${(err as Error).message}`, isError: true };
  }
}
