import sharp from "sharp";
import { captureScreenshot, click, doubleClick, doubleClickCurrent, moveMouse, typeText } from "./windowsAgentBridge";
import {
  confirmTextTyped,
  cropAroundPoint,
  cropToRegion,
  detectRowHeightRatio,
  diffBoundingBox,
  locateOnScreenshot,
  WindowBounds,
} from "./visionLocate";
import { getOcrWords, locateAllViaOcr, matchWordsForText } from "./ocrLocate";
import { backupTypeAgentScreenshot, logTypeAgentStep } from "../utils/screenshotBackup";

export type StepStatus = "running" | "done" | "error";
export type ProgressCallback = (
  index: number,
  total: number,
  label: string,
  status: StepStatus,
  error?: string
) => void;

interface RunContext {
  ip: string;
  userId: number;
  runId: string;
  patientLastName: string;
  patientFirstName: string;
  // Set once a dialog's bounds are detected (via diffBoundingBox) — every locate/click after
  // that point is restricted to this region until it's replaced by the next dialog's bounds.
  windowBounds: WindowBounds | null;
  // The screenshot taken right before the action that's expected to open the *next* dialog —
  // stashed here so the following step can diff against it without re-capturing.
  beforeNextWindow: string | null;
}

interface RunStep {
  label: string;
  execute: (ctx: RunContext) => Promise<void>;
  // Time to let the target app's UI settle/render before the next step's screenshot.
  delayAfterMs: number;
}

// Captures the current screen and, if a window has been detected, crops it down to just that
// window — so every locate call after that point only ever sees the relevant dialog, never the
// rest of the screen (the main window's title bar, side panels, etc.).
async function captureWindowScreenshot(ctx: RunContext): Promise<{ full: string; region: string }> {
  const { image } = await captureScreenshot(ctx.ip);
  const region = ctx.windowBounds ? await cropToRegion(image, ctx.windowBounds) : image;
  return { full: image, region };
}

// Translates a point from window-cropped-image coordinates back to real screen coordinates.
function toScreenPoint(ctx: RunContext, point: { x: number; y: number }): { x: number; y: number } {
  if (!ctx.windowBounds) return point;
  return { x: point.x + ctx.windowBounds.left, y: point.y + ctx.windowBounds.top };
}

// Same, for a whole list — used only when logging matches, so run.log always reports real
// screen coordinates (matching the full-screen debug screenshots) even though the underlying
// comparisons that pick a target run in cropped-image space.
function toScreenPoints(ctx: RunContext, points: { x: number; y: number }[]): { x: number; y: number }[] {
  return points.map((p) => toScreenPoint(ctx, p));
}

// Keeps a point inside the currently-tracked window — every movement/click is restricted to
// this region once one is set, so a bad locate can't send the cursor wandering off into the
// rest of the app.
function clampToWindow(ctx: RunContext, x: number, y: number): { x: number; y: number } {
  if (!ctx.windowBounds) return { x, y };
  const { left, top, width, height } = ctx.windowBounds;
  return {
    x: Math.min(Math.max(x, left), left + width - 1),
    y: Math.min(Math.max(y, top), top + height - 1),
  };
}

// Moves to (x, y) — clamped to the tracked window — saves a small marked debug crop of what's
// there (for after-the-fact review, not fed back to any AI call), then clicks.
async function debugClickAt(
  ctx: RunContext,
  rawX: number,
  rawY: number,
  label: string,
  opts?: { doubleClick?: boolean }
) {
  const { x, y } = clampToWindow(ctx, rawX, rawY);
  await moveMouse(x, y, ctx.ip);
  const { image } = await captureScreenshot(ctx.ip);
  const crop = await cropAroundPoint(image, x, y);
  backupTypeAgentScreenshot(ctx.runId, `${label}_debug`, crop);

  const action = opts?.doubleClick ? "double_click" : "click";
  console.log(`[type-agent] ${opts?.doubleClick ? "double-click" : "click"} at (${x}, ${y}) — ${label}`);
  logTypeAgentStep(ctx.runId, { event: action, label, pixelX: x, pixelY: y });
  if (opts?.doubleClick) {
    await doubleClick(x, y, ctx.ip);
  } else {
    await click(x, y, ctx.ip);
  }
}

// Detects a newly-opened dialog by diffing the screenshot stashed in ctx.beforeNextWindow
// against the current screen, and adopts its bounds as the window every subsequent locate/click
// is restricted to. The four corners (A/B/C/D) are just this rectangle's corners, logged for
// visibility.
async function detectAndTrackNewWindow(ctx: RunContext, label: string): Promise<void> {
  if (!ctx.beforeNextWindow) {
    throw new Error(`No "before" screenshot captured for detecting the ${label} window`);
  }
  const { image: after } = await captureScreenshot(ctx.ip);
  const bounds = await diffBoundingBox(ctx.beforeNextWindow, after);
  if (!bounds) {
    throw new Error(`Could not detect the ${label} window opening (no significant screen change)`);
  }
  ctx.windowBounds = bounds;
  ctx.beforeNextWindow = null;
  logTypeAgentStep(ctx.runId, {
    event: "window_detected",
    label,
    A: { x: bounds.left, y: bounds.top },
    B: { x: bounds.left + bounds.width, y: bounds.top },
    C: { x: bounds.left + bounds.width, y: bounds.top + bounds.height },
    D: { x: bounds.left, y: bounds.top + bounds.height },
  });
}

// Open Dental, "make an appointment for an existing patient":
//   1. Screenshot, then double-click the pre-positioned schedule slot.
//   2. Diff against that screenshot to find the newly-opened Select Patient dialog's bounds —
//      every following locate/click in this dialog is restricted to that region, so nothing
//      outside it (the main window's title bar, side panels) can ever be matched by mistake.
//   3. Click the Last Name field (found by vision, now unambiguous since the window is small
//      and cropped) and type the first three letters — no first name is typed.
//   4. Locate the target patient's first and last name text directly in the now-filtered results
//      grid (the 3-letter filter can return more than one patient) and pair up whichever
//      occurrences share a row to find the target row's real position, then double-click it
//      (opens Edit Appointment).
//   5. Diff again to find the Edit Appointment window's bounds, then click Exam and Save
//      within it.
const OPEN_DENTAL_EXISTING_PATIENT: RunStep[] = [
  {
    label: "Screenshot, then double-click the schedule at the cursor position",
    execute: async (ctx) => {
      const { image } = await captureScreenshot(ctx.ip);
      ctx.beforeNextWindow = image;
      await doubleClickCurrent(ctx.ip);
    },
    delayAfterMs: 1000, // Select Patient window opening
  },
  {
    label: "Detect the Select Patient window",
    execute: async (ctx) => {
      await detectAndTrackNewWindow(ctx, "Select Patient");
    },
    delayAfterMs: 100,
  },
  {
    label: "Find and click the Last Name field, type the first three letters",
    execute: async (ctx) => {
      const lastThree = ctx.patientLastName.slice(0, 3);
      const { region } = await captureWindowScreenshot(ctx);
      const loc = await locateOnScreenshot(
        ctx.userId,
        region,
        'the text input field immediately to the right of the "Last Name" label'
      );
      if (!loc) throw new Error('Could not find the "Last Name" field in the Select Patient window');
      const screen = toScreenPoint(ctx, loc);
      logTypeAgentStep(ctx.runId, { event: "locate", label: "Last Name field", pixelX: screen.x, pixelY: screen.y });

      await debugClickAt(ctx, screen.x, screen.y, "last_name_field");
      await typeText(lastThree, ctx.ip);
    },
    delayAfterMs: 600, // patient list filtering
  },
  {
    label: "Verify the last name fragment was typed",
    execute: async (ctx) => {
      const { full } = await captureWindowScreenshot(ctx);
      backupTypeAgentScreenshot(ctx.runId, "typed_check", full);
      const lastThree = ctx.patientLastName.slice(0, 3);
      const typed = await confirmTextTyped(ctx.userId, full, lastThree);
      logTypeAgentStep(ctx.runId, { event: "typed_check", typed });
      if (!typed) {
        throw new Error(`"${lastThree}" doesn't appear to have been typed anywhere on screen`);
      }
    },
    delayAfterMs: 200,
  },
  {
    label: "Find and double-click the matching patient row",
    execute: async (ctx) => {
      const { region } = await captureWindowScreenshot(ctx);
      backupTypeAgentScreenshot(ctx.runId, "patient_row_locate", region);

      // One OCR call for the whole region, matched against locally for each of the three text
      // targets below — Vision bills per call, and all three targets live in this same
      // screenshot, so there's no reason to pay for three separate OCR passes over it.
      const ocrWords = await getOcrWords(region);

      const headerMatches = matchWordsForText(ocrWords, "PatNum");
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "PatNum header", matches: toScreenPoints(ctx, headerMatches) });
      if (headerMatches.length === 0) throw new Error('Could not find the "PatNum" column header');
      const header = headerMatches.reduce((a, b) => (a.y < b.y ? a : b));

      // Locate the target patient's first and last name directly via OCR (real pixel bounding
      // boxes from Google Vision), rather than computing the row's y arithmetically (headerY +
      // rowHeight * rowIndex) — a previous version did that and the click drifted further off-row
      // the more rows down the target was. An earlier AI-vision version of this step (asking
      // Claude to estimate each word's top/bottom edge) also had a small but consistent upward
      // bias since it was an LLM's visual estimate, not a measurement. OCR bounding-box centers
      // are exact, so the same-row pairing below needs no bias correction.
      const lastNameMatches = matchWordsForText(ocrWords, ctx.patientLastName);
      const firstNameMatches = matchWordsForText(ocrWords, ctx.patientFirstName);
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "last name", matches: toScreenPoints(ctx, lastNameMatches) });
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "first name", matches: toScreenPoints(ctx, firstNameMatches) });
      if (lastNameMatches.length === 0) throw new Error(`Could not find "${ctx.patientLastName}" in the patient list`);
      if (firstNameMatches.length === 0) throw new Error(`Could not find "${ctx.patientFirstName}" in the patient list`);

      // Row height is only used here as a "same row" tolerance for the pairing below, not
      // multiplied by anything — so any imprecision in it no longer compounds with row distance.
      const rowHeightRatio = await detectRowHeightRatio(ctx.userId, region);
      const rowHeightPx = Math.round(rowHeightRatio * (ctx.windowBounds?.height ?? 0));
      if (rowHeightPx <= 0) throw new Error(`AI reported an invalid row height ratio: ${rowHeightRatio}`);

      let best: { last: { x: number; y: number }; first: { x: number; y: number }; dist: number } | null = null;
      for (const last of lastNameMatches) {
        for (const first of firstNameMatches) {
          const dist = Math.abs(last.y - first.y);
          if (dist <= rowHeightPx / 2 && (!best || dist < best.dist)) {
            best = { last, first, dist };
          }
        }
      }
      if (!best) {
        throw new Error(
          `Could not find a row where "${ctx.patientFirstName}" and "${ctx.patientLastName}" are on the same line`
        );
      }

      const screen = toScreenPoint(ctx, { x: header.x, y: best.last.y });
      logTypeAgentStep(ctx.runId, {
        event: "locate",
        label: "matching patient row",
        pixelX: screen.x,
        finalY: screen.y,
        lastNameMatch: toScreenPoint(ctx, best.last),
        firstNameMatch: toScreenPoint(ctx, best.first),
        rowHeightPx,
      });

      ctx.beforeNextWindow = (await captureScreenshot(ctx.ip)).image;
      await debugClickAt(ctx, screen.x, screen.y, "patient_row", { doubleClick: true });
    },
    delayAfterMs: 1200, // Edit Appointment window opening
  },
  {
    label: "Detect the Edit Appointment window",
    execute: async (ctx) => {
      await detectAndTrackNewWindow(ctx, "Edit Appointment");
    },
    delayAfterMs: 100,
  },
  {
    label: "Find and click the Exam procedure in the upper middle column",
    execute: async (ctx) => {
      const { region } = await captureWindowScreenshot(ctx);
      backupTypeAgentScreenshot(ctx.runId, "exam_locate", region);

      // The Edit Appointment window is laid out in three big panels: Patient Info (left), the
      // clickable single-click procedures list (middle), and the already-added procedures grid
      // plus comm log (right). "Exam" appears both as a clickable item in the middle list AND
      // as an already-added row in the right grid — telling them apart by y-position alone isn't
      // reliable since both can land at a similar height. Column-divider detection (asking AI to
      // find the panel-separator lines) used to disambiguate this, but that's a visual-layout
      // judgment call prone to over/under-counting dividers (seen in practice: 3 found instead of
      // 2, hard-failing the whole step). "Single click" is a hint label that only ever appears
      // once, directly above the correct clickable list — so the "Exam" occurrence nearest it in
      // x is structurally the right one, without needing to know where the panel boundaries are.
      // One OCR call for the whole region, matched locally against both text targets — same
      // reasoning as the patient-row step above.
      const ocrWords = await getOcrWords(region);
      const examMatches = matchWordsForText(ocrWords, "Exam");
      const singleClickMatches = matchWordsForText(ocrWords, "Single click");
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Exam", matches: toScreenPoints(ctx, examMatches) });
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Single click", matches: toScreenPoints(ctx, singleClickMatches) });

      if (examMatches.length === 0) throw new Error('Could not find "Exam" in the Edit Appointment window');
      if (singleClickMatches.length === 0) throw new Error('Could not find "Single click" in the Edit Appointment window');

      // Topmost "Single click" occurrence, in case OCR ever reports more than one.
      const singleClick = singleClickMatches.reduce((a, b) => (a.y < b.y ? a : b));

      // Nearest "Exam" match to that x — the one in the unrelated right-side grid sits in a
      // different column, far enough in x not to compete with the real target.
      const examLoc = examMatches.reduce((a, b) =>
        Math.abs(a.x - singleClick.x) <= Math.abs(b.x - singleClick.x) ? a : b
      );

      // OCR's bounding-box center is exact, so the click lands directly on it — no row-height
      // grid estimate needed to nudge toward a row's middle.
      const screen = toScreenPoint(ctx, examLoc);
      logTypeAgentStep(ctx.runId, {
        event: "exam_match",
        pixelX: screen.x,
        finalY: screen.y,
        singleClickMatch: toScreenPoint(ctx, singleClick),
      });

      await debugClickAt(ctx, screen.x, screen.y, "exam");
    },
    delayAfterMs: 500,
  },
  {
    label: "Find and click Save",
    execute: async (ctx) => {
      const { region } = await captureWindowScreenshot(ctx);
      backupTypeAgentScreenshot(ctx.runId, "save_locate", region);

      const saveMatches = await locateAllViaOcr(region, "Save");
      logTypeAgentStep(ctx.runId, { event: "locate_all", label: "Save", matches: toScreenPoints(ctx, saveMatches) });
      if (saveMatches.length === 0) throw new Error("Could not find the Save button in the Edit Appointment window");

      // "Save" only ever appears once as a button label in this window, but if OCR ever reports
      // more than one, the real one is in the LOWER part of the RIGHTMOST column (below the other
      // action buttons there, e.g. "Delete", "To Task List", "Audit Trail") — so the bottom-right-
      // most occurrence is the correct disambiguation, same reasoning the old AI-vision prompt used.
      const saveLoc = saveMatches.reduce((a, b) => (a.x + a.y >= b.x + b.y ? a : b));

      const screen = toScreenPoint(ctx, saveLoc);
      logTypeAgentStep(ctx.runId, { event: "locate", label: "Save", pixelX: screen.x, pixelY: screen.y });

      await debugClickAt(ctx, screen.x, screen.y, "save");
    },
    delayAfterMs: 300,
  },
];

async function runSteps(ctx: RunContext, steps: RunStep[], onProgress: ProgressCallback): Promise<void> {
  logTypeAgentStep(ctx.runId, {
    event: "run_start",
    patientLastName: ctx.patientLastName,
    patientFirstName: ctx.patientFirstName,
    ip: ctx.ip,
    totalSteps: steps.length,
  });
  for (let i = 0; i < steps.length; i++) {
    const step = steps[i]!;
    onProgress(i, steps.length, step.label, "running");
    logTypeAgentStep(ctx.runId, { event: "step_start", index: i, label: step.label });
    try {
      await step.execute(ctx);
      onProgress(i, steps.length, step.label, "done");
      logTypeAgentStep(ctx.runId, { event: "step_done", index: i, label: step.label });
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      onProgress(i, steps.length, step.label, "error", message);
      logTypeAgentStep(ctx.runId, { event: "step_error", index: i, label: step.label, error: message });
      throw err;
    }
    await new Promise((resolve) => setTimeout(resolve, step.delayAfterMs));
  }
  logTypeAgentStep(ctx.runId, { event: "run_done" });
}

export async function runExistingPatientOpenDental(
  userId: number,
  runId: string,
  ip: string,
  patientLastName: string,
  patientFirstName: string,
  onProgress: ProgressCallback
): Promise<void> {
  const ctx: RunContext = {
    ip,
    userId,
    runId,
    patientLastName,
    patientFirstName,
    windowBounds: null,
    beforeNextWindow: null,
  };
  await runSteps(ctx, OPEN_DENTAL_EXISTING_PATIENT, onProgress);
}

// TEMPORARY TEST PATH — types the full last name directly (relying on the Select Patient
// window's default focus, no field click) and then moves to a hardcoded ratio (0.31, 0.21) —
// the position manually read off a real "Allowed, Allen" row screenshot — instead of any
// vision lookup. Exists purely to test the ratio→pixel conversion and move/double-click
// mechanics in isolation from AI locate mistakes. Kept separate from the real flow above,
// which now types into both name fields individually and uses window-relative locates instead.
const TEST_ROW_X_RATIO = 0.31;
const TEST_ROW_Y_RATIO = 0.21;

const OPEN_DENTAL_EXISTING_PATIENT_RATIO_TEST: RunStep[] = [
  {
    label: "Double-click the schedule at the cursor position",
    execute: async (ctx) => {
      await doubleClickCurrent(ctx.ip);
    },
    delayAfterMs: 1000, // Select Patient window opening
  },
  {
    label: "Type the patient's last name (no field click first — relies on default focus)",
    execute: async (ctx) => {
      await typeText(ctx.patientLastName, ctx.ip);
    },
    delayAfterMs: 600, // patient list filtering
  },
  {
    label: "TEST: verify the last name was typed",
    execute: async (ctx) => {
      const { image } = await captureScreenshot(ctx.ip);
      backupTypeAgentScreenshot(ctx.runId, `ratio_test_typed_check_${ctx.patientLastName}`, image);
      const typed = await confirmTextTyped(ctx.userId, image, ctx.patientLastName);
      if (!typed) {
        throw new Error(`"${ctx.patientLastName}" doesn't appear to have been typed anywhere on screen`);
      }
    },
    delayAfterMs: 200,
  },
  {
    label: `TEST: move to fixed ratio (${TEST_ROW_X_RATIO}, ${TEST_ROW_Y_RATIO}) and double-click`,
    execute: async (ctx) => {
      const { image } = await captureScreenshot(ctx.ip);
      const { width = 0, height = 0 } = await sharp(Buffer.from(image, "base64")).metadata();
      const x = Math.round(TEST_ROW_X_RATIO * width);
      const y = Math.round(TEST_ROW_Y_RATIO * height);

      console.log(`[type-agent] TEST double-click at ratio (${TEST_ROW_X_RATIO}, ${TEST_ROW_Y_RATIO}) -> pixel (${x}, ${y})`);
      await moveMouse(x, y, ctx.ip);
      await doubleClick(x, y, ctx.ip);
    },
    delayAfterMs: 1200,
  },
];

export async function runExistingPatientOpenDentalRatioTest(
  userId: number,
  runId: string,
  ip: string,
  patientLastName: string,
  patientFirstName: string,
  onProgress: ProgressCallback
): Promise<void> {
  const ctx: RunContext = {
    ip,
    userId,
    runId,
    patientLastName,
    patientFirstName,
    windowBounds: null,
    beforeNextWindow: null,
  };
  await runSteps(ctx, OPEN_DENTAL_EXISTING_PATIENT_RATIO_TEST, onProgress);
}
