// サイトの計測を Claude につなぐ部品（MCP サーバー。2026-11-06）。
// Claude.ai のコネクタとして https://pixelstomotors.com/mcp/{鍵} を登録すると、Claude が集計済みの数字と、
// その読み方（何が混ざりうるか・いつの数から使えるか）をまとめて受け取れる。
// 毎回の分析で「鍵をどこから読むか・どの表から見るか・どこに落とし穴があるか」を Claude が思い出さなくて済むようにするため。
//
// - 読むだけ（数を変える道具は record_name_search の1つだけで、Search Console の数を週ごとに書き足す）
// - 鍵は Cloudflare の秘密の値 MCP_KEY。なければ STATS_KEY から作った値（mcpKey()）を使う。鍵が違えば 404
// - 通信の形は MCP の Streamable HTTP（POST で JSON-RPC を1つ受け、JSON で1つ返す。状態は持たない）

import { kpiRange, todayJst, addDays, weekOf, KPI_FIRST_DAY } from './kpi.js';
import { gscRefresh, gscReady } from './gsc.js';

const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
const SERVER = { name: 'pixelstomotors-stats', title: 'Pixels to Motors のサイトの計測', version: '1.0.0' };

// 読み方の知識（道具の説明と how_to_read で返す）。数字の前提が変わったらここを直す
const GUIDE = `# Pixels to Motors（pixelstomotors.com）のサイトの計測の読み方

## いま主に見る3つの数字（2026-11-06 にエイイチが決めた）
サイトのねらい：AI に調べられることで、人の来訪や知名度がいつ増えはじめるかを見る。サイトはもともと検索で人を集める作りにしていない。
1. AI の利用者の依頼で読みに来た数（aiReads）：ChatGPT-User・Claude-User・Perplexity-User・MistralAI-User が読んだページの数。/robots.txt は数えない。ChatGPT-User のうち Cloudflare が本物と確かめられなかったものは入れず、unverifiedChatGPTExcluded に別に数える。Claude-User は Cloudflare が確かめないので全部入れる
2. AI のサービスのリンクから来た人の数（aiReferrals）：referrer が chatgpt.com などか、リンクの印 utm_source が chatgpt.com などの表示のうち、人の操作（マウス・指・キー）があったものだけ。機械・操作なしは notHumanExcluded に別に数える
3. サイト名で検索して来た人の数（nameSearch）：Search Console のサイト名（Pixels to Motors・pixelstomotors など）の検索のクリック数。今は週の分析のときに Claude が Search Console（Chrome 拡張・新旧ドメインの両方）を見て、record_name_search で週ごとに書き足す（2026-11-06 決定。API の自動取得は Google Workspace の組織のポリシーでサービスアカウントの鍵が作れず保留。鍵 GSC_SA_KEY を入れれば毎日自動に切り替わる）。数は2〜3日遅れて確定し、人数の少ない検索語は Search Console が伏せるので少なめに出る。kpi の nameSearch に週ごとの記録（byDay・検索語・検索全体のクリック allClicks）が出る

## 混ざりうるもの（数を読む前に必ず思い出す）
- aiReads の viaButton は記事の「AIで読む」ボタン経由。エイイチや Claude の試しが混ざりうる。ボタンの利用そのものは site_usage の押したもの（btn:AIで読む・場所 ai）で人のクリックを見る。crawler_visits の byVia は Amazonbot などのクローラーがボタンの行き先をたどった分が大半で、利用の数としては使えない（2026-11-05 に判明）
- aiReads には、エイイチ自身が ChatGPT・Claude にこのサイトのことを聞いた分も入りうる（見分けられない）。トップ（/）が多い日はそれを疑う
- Claude のセッションが WebFetch でサイトを読むと Claude-User として入りうる
- 人の数は 2026-10-26 から（それより前は人と機械を分けていない）。人の表示の約9割は機械なので、来訪の数は必ず人だけ（who=human）で見る
- エイイチ・Claude の確認の閲覧は ?p2m-notrack=1 を付けたブラウザでは送られない。付け忘れた自動のブラウザは navigator.webdriver で機械に入る
- crawler_visits（/api/cr-stats）は長い期間を指定すると記録が多すぎて一部しか数えない（truncated が true）。2026-10-25 からの合計は少なめに出ていた。日ごとの数は kpi（日ごとに集計して残したもの）で見る

## 数え方の決まり
- 日付は日本時間。週は月曜はじまり
- 改善の判断の基準（Project の claude/web_improvement_trial.md）：対象の数が30未満なら「判断できない」
- 見た人を見分ける番号は残さないので、「何人か」は数えられない。数は表示・訪問の回数

## 道具の使い分け
- まず kpi（3つの数字・日ごと週ごと）
- ページごとの読まれ方・押したもの・来た所は site_usage
- どのクローラーが何を読んだかは crawler_visits（期間は短く）
- 更新を知らせるハコブくんは hakobu_notice`;

const TOOLS = [
  {
    name: 'kpi',
    title: '3つの数字（日ごと・週ごと）',
    description: 'いま主に見る3つの数字を返す：AI の利用者の依頼で読みに来た数・AI のサービスのリンクから来た人の数・サイト名で検索して来た人の数（Search Console から記録した分）。日ごとに集計して残したものなので速く、数え漏れがない。混ざりうるものは how_to_read を読むこと。日付は日本時間、週は月曜はじまり。',
    inputSchema: { type: 'object', properties: {
      since: { type: 'string', description: '始めの日（YYYY-MM-DD）。省略すると28日前。2026-10-26 より前は数えない' },
      until: { type: 'string', description: '終わりの日（YYYY-MM-DD）。省略すると今日（今日は途中の数）' },
      detail: { type: 'boolean', description: 'true でよく読まれたページ・来たページなどの内訳も返す' },
    } },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'site_usage',
    title: 'サイトの使われ方（ページごと・押したもの・来た所）',
    description: 'ページ表示ごとの記録（/api/ev-stats）。週ごと・ページごとの表示・表示時間・どこまで読んだか・押したもの・リンクでない所を押した・どこから来たか・フォーム。既定は人の操作があった表示だけ（who=human）。',
    inputSchema: { type: 'object', properties: {
      since: { type: 'string', description: 'この日以降（YYYY-MM-DD）' },
      week: { type: 'string', description: 'ページごと以下の表をこの週だけに（週の月曜の日付）' },
      who: { type: 'string', enum: ['human', 'bot', 'unknown', 'old', 'all'], description: '既定 human' },
    } },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'crawler_visits',
    title: '検索エンジン・AI のクローラーの訪問',
    description: 'どのクローラーが・どのページを読んだか（/api/cr-stats）。返せなかったページ（400以上）も。長い期間を指定すると一部しか数えない（truncated）ので、since は2週間以内にする。',
    inputSchema: { type: 'object', properties: {
      since: { type: 'string', description: 'この日以降（YYYY-MM-DD）。省略すると7日前' },
      bot: { type: 'string', description: 'ページごとの表をこのクローラーだけに（例 ChatGPT-User）' },
    } },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'hakobu_notice',
    title: '更新を知らせるハコブくんの記録',
    description: 'サイトの更新を知らせてハコブくんが出た・押された・あとで見に来た数（/api/hk-stats）。既定は機械の印のない記録だけ（who=notbot）。',
    inputSchema: { type: 'object', properties: { who: { type: 'string', enum: ['notbot', 'bot', 'old', 'all'], description: '既定 notbot' } } },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'how_to_read',
    title: '計測の読み方',
    description: '数を読む前の前提：3つの数字の定義、混ざりうるもの（エイイチ・Claude の試し、Amazonbot、人と機械）、いつからの数が使えるか、道具の使い分け。分析を始めるときに最初に読む。',
    inputSchema: { type: 'object', properties: {} },
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  {
    name: 'refresh_name_search',
    title: 'サイト名の検索の数を Search Console から取り直す',
    description: '毎日自動で取っているサイト名の検索の数を、今すぐ取り直す。since を付けるとその週から（最初の埋め戻しや、数が確定したあとの取り直し用）。',
    inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'この日の週から（YYYY-MM-DD）。省略すると先週から' } } },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
  },
  {
    name: 'record_name_search',
    title: 'サイト名の検索の数を記録する（Search Console から）',
    description: 'Search Console の API が使えないときだけ使う。Search Console で見た、サイト名（Pixels to Motors・pixelstomotors など）で検索された週の数を書き足す。同じ週を送ると上書きする。数は2〜3日遅れて出るので、週が終わって3日以上たってから記録する。',
    inputSchema: { type: 'object', required: ['week', 'clicks', 'impressions'], properties: {
      week: { type: 'string', description: '週の月曜の日付（YYYY-MM-DD）' },
      clicks: { type: 'integer', minimum: 0, description: 'サイト名の検索から来たクリック数（新旧ドメインの合計）' },
      impressions: { type: 'integer', minimum: 0, description: 'サイト名の検索で表示された回数（新旧ドメインの合計）' },
      queries: { type: 'array', items: { type: 'object', properties: { query: { type: 'string' }, clicks: { type: 'integer' }, impressions: { type: 'integer' } } }, description: '数えた検索語ごとの内訳' },
      note: { type: 'string', description: 'どのプロパティを見たか・気づいたこと' },
    } },
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
];

const DAY = /^\d{4}-\d{2}-\d{2}$/;
const okDay = (s, d) => (DAY.test(s || '') ? s : d);

// MCP の鍵。MCP_KEY があればそれ、なければ STATS_KEY から作る（STATS_KEY を変えればこちらも変わる）
export async function mcpKey(env) {
  if (env.MCP_KEY) return env.MCP_KEY;
  if (!env.STATS_KEY) return '';
  const k = await crypto.subtle.importKey('raw', new TextEncoder().encode(env.STATS_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const sig = new Uint8Array(await crypto.subtle.sign('HMAC', k, new TextEncoder().encode('p2m-mcp-v1')));
  return [...sig.slice(0, 16)].map((b) => b.toString(16).padStart(2, '0')).join('');
}

async function sameKey(given, key) {
  if (!given || !key) return false;
  const [a, b] = await Promise.all([given, key].map((x) => crypto.subtle.digest('SHA-256', new TextEncoder().encode(x))));
  return crypto.subtle.timingSafeEqual(a, b);
}

const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
const rpcErr = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
const text = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });

// stats: { evStats, crStats, hkStats }（index.js の数の表。Response を返す関数）
export async function handleMcp(request, env, url, stats) {
  const given = url.pathname.slice('/mcp/'.length).replace(/\/$/, '');
  if (!(await sameKey(given, await mcpKey(env)))) return new Response('Not Found', { status: 404, headers: { 'x-robots-tag': 'noindex' } });
  const headers = { 'content-type': 'application/json; charset=utf-8', 'x-robots-tag': 'noindex', 'cache-control': 'no-store' };
  if (request.method === 'GET') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
  if (request.method === 'DELETE') return new Response(null, { status: 204 });
  if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
  let msg;
  try { msg = JSON.parse((await request.text()).slice(0, 100000)); } catch { return new Response(JSON.stringify(rpcErr(null, -32700, 'Parse error')), { status: 400, headers }); }
  const batch = Array.isArray(msg);
  const out = [];
  for (const m of batch ? msg : [msg]) {
    const r = await one(m, env, url, stats);
    if (r) out.push(r);
  }
  if (!out.length) return new Response(null, { status: 202 }); // 通知（返事のいらないもの）だけだった
  return new Response(JSON.stringify(batch ? out : out[0]), { headers });
}

async function one(m, env, url, stats) {
  if (!m || m.jsonrpc !== '2.0' || typeof m.method !== 'string') return rpcErr(m && m.id, -32600, 'Invalid Request');
  const isNotice = !('id' in m);
  try {
    switch (m.method) {
      case 'initialize': {
        const want = m.params && m.params.protocolVersion;
        return rpc(m.id, {
          protocolVersion: PROTOCOLS.includes(want) ? want : PROTOCOLS[0],
          capabilities: { tools: { listChanged: false } },
          serverInfo: SERVER,
          instructions: 'pixelstomotors.com のサイトの計測。分析を始めるときは、まず how_to_read で前提を読み、次に kpi で3つの数字を見る。',
        });
      }
      case 'ping': return isNotice ? null : rpc(m.id, {});
      case 'tools/list': return rpc(m.id, { tools: TOOLS });
      case 'tools/call': return rpc(m.id, await call(m.params || {}, env, url, stats));
      case 'resources/list': return rpc(m.id, { resources: [] });
      case 'prompts/list': return rpc(m.id, { prompts: [] });
      default:
        if (isNotice) return null; // notifications/initialized など
        return rpcErr(m.id, -32601, `Method not found: ${m.method}`);
    }
  } catch (e) {
    return isNotice ? null : rpcErr(m.id, -32603, String(e && e.message || e));
  }
}

// index.js の数の表を、鍵を付けた形で中から呼んで JSON を受け取る
async function statsJson(fn, env, path, params) {
  const u = new URL(`https://pixelstomotors.com${path}`);
  u.searchParams.set('format', 'json');
  for (const [k, v] of Object.entries(params)) if (v) u.searchParams.set(k, v);
  const res = await fn(new Request(u, { method: 'GET' }), env, u);
  return res.json();
}

async function call(p, env, url, stats) {
  const a = p.arguments || {};
  const today = todayJst();
  switch (p.name) {
    case 'how_to_read': return text(GUIDE);
    case 'kpi': {
      const r = await kpiRange(env, okDay(a.since, addDays(today, -28)), okDay(a.until, today));
      if (!a.detail) delete r.detailDays;
      return text({ note: '定義と混ざりうるものは how_to_read。byDay の partial=true は今日（途中の数）。pendingDays は集計がまだの日（もう一度呼ぶと作る）。', firstDay: KPI_FIRST_DAY, ...r });
    }
    case 'site_usage': {
      const who = a.who === 'all' ? '' : (a.who || 'human');
      return text(await statsJson(stats.evStats, env, '/api/ev-stats', { since: okDay(a.since, ''), week: okDay(a.week, ''), who }));
    }
    case 'crawler_visits':
      return text(await statsJson(stats.crStats, env, '/api/cr-stats', { since: okDay(a.since, addDays(today, -7)), bot: String(a.bot || '').slice(0, 40) }));
    case 'hakobu_notice': {
      const who = a.who === 'all' ? '' : (a.who || 'notbot');
      return text(await statsJson(stats.hkStats, env, '/api/hk-stats', { who }));
    }
    case 'refresh_name_search': {
      if (!gscReady(env)) return { isError: true, ...text('Search Console の鍵（GSC_SA_KEY）がまだ設定されていない') };
      return text(await gscRefresh(env, { since: okDay(a.since, '') }));
    }
    case 'record_name_search': {
      if (!DAY.test(a.week || '') || weekOf(a.week) !== a.week) return { isError: true, ...text('week は週の月曜の日付（YYYY-MM-DD）にする') };
      const n = (x) => Math.max(0, Math.floor(Number(x) || 0));
      const rec = {
        week: a.week, clicks: n(a.clicks), impressions: n(a.impressions),
        queries: (Array.isArray(a.queries) ? a.queries : []).slice(0, 50).map((q) => ({ query: String(q.query || '').slice(0, 100), clicks: n(q.clicks), impressions: n(q.impressions) })),
        note: String(a.note || '').slice(0, 1000), source: 'Search Console', recordedAt: new Date().toISOString(),
      };
      await env.MESSAGES.put(`kpi-sc/${a.week}.json`, JSON.stringify(rec), { httpMetadata: { contentType: 'application/json' } });
      return text({ ok: true, saved: `kpi-sc/${a.week}.json`, ...rec });
    }
    default:
      return { isError: true, ...text(`知らない道具：${p.name}`) };
  }
}
