// useSnapshotEditor — reducer + pure-helper unit tests (Issue #213).
//
// The project has no jsdom / @testing-library renderHook harness (see the note
// in StarterAccountsModal.test.tsx), so we test the EXTRACTED pure pieces of
// the editor's state machine directly: the `reducer`, the LOADED/prefill
// hydration mapper `holdingsFromServiceHoldings`, and the save builders
// `buildSimpleLines` / `buildDetailedLines`. This covers the holdings actions
// (ADD/REMOVE/SET_HOLDING_FIELD), prefill qty-0 exclusion + price drop, and the
// dispatch-on-account.kind invariant (a detailed account under a `simple`
// category still flows through the holdings path).

import { describe, it, expect, vi } from "vitest";

// db is imported transitively (hook → balance.service → db → tauri plugins).
// Stub it so no module-load path tries a real connection.
vi.mock("../services/db", () => {
  const getDb = vi.fn();
  return {
    getDb,
    connectToProfile: vi.fn(),
    initializeNewProfileDb: vi.fn(),
    withTransaction: vi.fn(async (fn: (db: any) => any) => fn(await getDb())),
  };
});

import {
  reducer,
  initialState,
  makeEmptyHolding,
  holdingsFromServiceHoldings,
  holdingsFromCsvRows,
  buildSimpleLines,
  buildDetailedLines,
  type HoldingDraft,
} from "./useSnapshotEditor";
import type { HoldingColumnMapping } from "../utils/csvAutoDetect";
import { BalanceServiceError } from "../services/balance.service";
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";

function holdingRow(
  over: Partial<BalanceSnapshotHoldingWithSecurity>
): BalanceSnapshotHoldingWithSecurity {
  return {
    id: 1,
    snapshot_line_id: 10,
    security_id: 100,
    quantity: 5,
    unit_price: 20,
    value: 100,
    book_cost: 80,
    price_source: "manual",
    price_fetched_at: null,
    created_at: "2026-01-01",
    updated_at: "2026-01-01",
    security_symbol: "AAPL",
    security_name: "Apple Inc.",
    security_asset_type: "stock",
    ...over,
  };
}

const base = initialState("2026-06-06");

describe("reducer — holdings actions (#213)", () => {
  it("ADD_HOLDING appends a draft to the account's basket", () => {
    const h = makeEmptyHolding("stock");
    const next = reducer(base, {
      type: "ADD_HOLDING",
      payload: { accountId: 42, holding: h },
    });
    expect(next.holdings[42]).toHaveLength(1);
    expect(next.holdings[42][0].rowId).toBe(h.rowId);
    expect(next.isDirty).toBe(true);
  });

  it("ADD_HOLDING keeps other accounts' baskets untouched", () => {
    const s1 = reducer(base, {
      type: "ADD_HOLDING",
      payload: { accountId: 1, holding: makeEmptyHolding() },
    });
    const s2 = reducer(s1, {
      type: "ADD_HOLDING",
      payload: { accountId: 2, holding: makeEmptyHolding() },
    });
    expect(s2.holdings[1]).toHaveLength(1);
    expect(s2.holdings[2]).toHaveLength(1);
  });

  it("SET_HOLDING_FIELD updates only the targeted row + field", () => {
    const a = makeEmptyHolding();
    const b = makeEmptyHolding();
    let s = reducer(base, {
      type: "ADD_HOLDING",
      payload: { accountId: 7, holding: a },
    });
    s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
    s = reducer(s, {
      type: "SET_HOLDING_FIELD",
      payload: { accountId: 7, rowId: b.rowId, field: "quantity", value: "12" },
    });
    expect(s.holdings[7].find((h) => h.rowId === a.rowId)!.quantity).toBe("");
    expect(s.holdings[7].find((h) => h.rowId === b.rowId)!.quantity).toBe("12");
  });

  it("REMOVE_HOLDING drops the targeted row, preserving the rest", () => {
    const a = makeEmptyHolding();
    const b = makeEmptyHolding();
    let s = reducer(base, {
      type: "ADD_HOLDING",
      payload: { accountId: 7, holding: a },
    });
    s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
    s = reducer(s, {
      type: "REMOVE_HOLDING",
      payload: { accountId: 7, rowId: a.rowId },
    });
    expect(s.holdings[7]).toHaveLength(1);
    expect(s.holdings[7][0].rowId).toBe(b.rowId);
  });

  it("makeEmptyHolding gives each row a distinct rowId", () => {
    expect(makeEmptyHolding().rowId).not.toBe(makeEmptyHolding().rowId);
  });

  it("RESET wipes both values and holdings", () => {
    let s = reducer(base, {
      type: "SET_VALUE",
      payload: { accountId: 1, value: "100" },
    });
    s = reducer(s, {
      type: "ADD_HOLDING",
      payload: { accountId: 2, holding: makeEmptyHolding() },
    });
    s = reducer(s, { type: "RESET" });
    expect(s.values).toEqual({});
    expect(s.holdings).toEqual({});
  });

  it("PREFILL merges simple values and detailed baskets", () => {
    const draft = makeEmptyHolding();
    const s = reducer(base, {
      type: "PREFILL",
      payload: { values: { 1: "500" }, holdings: { 2: [draft] } },
    });
    expect(s.values[1]).toBe("500");
    expect(s.holdings[2][0].rowId).toBe(draft.rowId);
    expect(s.isDirty).toBe(true);
  });
});

describe("holdingsFromServiceHoldings — LOADED vs PREFILL (#213)", () => {
  it("LOADED (keepPrice) carries qty, price, book_cost and source", () => {
    const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
    const [d] = holdingsFromServiceHoldings(rows, { keepPrice: true });
    expect(d.symbol).toBe("AAPL");
    expect(d.asset_type).toBe("stock");
    expect(d.quantity).toBe("5");
    expect(d.unit_price).toBe("20");
    expect(d.book_cost).toBe("80");
    expect(d.price_source).toBe("manual");
  });

  it("PREFILL (keepPrice=false) drops price + source, keeps qty + book_cost", () => {
    const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
    const [d] = holdingsFromServiceHoldings(rows, { keepPrice: false });
    expect(d.quantity).toBe("5");
    expect(d.book_cost).toBe("80");
    // Price is re-entered / re-fetched each snapshot, so it is intentionally blank.
    expect(d.unit_price).toBe("");
    expect(d.price_source).toBeNull();
    expect(d.price_fetched_at).toBeNull();
  });

  it("maps a NULL book_cost to an empty string", () => {
    const [d] = holdingsFromServiceHoldings([holdingRow({ book_cost: null })], {
      keepPrice: true,
    });
    expect(d.book_cost).toBe("");
  });

  it("preserves the per-security rows the service returns (qty-0 already excluded upstream)", () => {
    // getHoldingsForLatestSnapshot excludes qty-0 server-side; the mapper is a
    // faithful 1:1 projection — a sold-then-rebought title arrives as its
    // latest non-zero holding and is mapped like any other.
    const rows = [
      holdingRow({ security_symbol: "AAPL", quantity: 3 }),
      holdingRow({ security_symbol: "MSFT", quantity: 7, security_id: 101 }),
    ];
    const drafts = holdingsFromServiceHoldings(rows, { keepPrice: false });
    expect(drafts.map((d) => d.symbol)).toEqual(["AAPL", "MSFT"]);
    expect(drafts.map((d) => d.quantity)).toEqual(["3", "7"]);
  });
});

describe("buildSimpleLines — scalar save path (#213)", () => {
  it("emits only non-detailed accounts with a non-empty value", () => {
    const lines = buildSimpleLines(
      { 1: "100", 2: " ", 3: "250", 4: "999" },
      new Set([4]) // account 4 is detailed → must NOT appear here
    );
    expect(lines).toEqual([
      { account_id: 1, value: 100, account_kind: "simple" },
      { account_id: 3, value: 250, account_kind: "simple" },
    ]);
  });

  it("accepts comma decimals and throws on a non-numeric value", () => {
    expect(buildSimpleLines({ 1: "12,5" }, new Set())[0].value).toBe(12.5);
    expect(() => buildSimpleLines({ 1: "abc" }, new Set())).toThrow(
      BalanceServiceError
    );
  });
});

describe("buildDetailedLines — holdings save path (#213)", () => {
  function draft(over: Partial<HoldingDraft>): HoldingDraft {
    return {
      ...makeEmptyHolding("stock"),
      ...over,
    };
  }

  it("builds one line per detailed account, value = rounded-cent SUM of holdings", () => {
    const holdings = {
      5: [
        draft({ symbol: "AAPL", quantity: "2", unit_price: "10.005", book_cost: "15" }),
        draft({ symbol: "MSFT", quantity: "1", unit_price: "30.10" }),
      ],
    };
    const [line] = buildDetailedLines(holdings, new Set([5]));
    expect(line.account_id).toBe(5);
    expect(line.holdings).toHaveLength(2);
    // AAPL 2 * 10.005 = 20.01 (rounded), MSFT 1 * 30.10 = 30.10 → 50.11.
    expect(line.holdings![0].value).toBe(20.01);
    expect(line.value).toBe(50.11);
    // The aggregated line carries the holdings field (marks it detailed) and
    // no scalar qty/price.
    expect(line.holdings).toBeDefined();
    expect(line.quantity).toBeUndefined();
    expect(line.unit_price).toBeUndefined();
  });

  it("emits an EMPTY holdings array for a detailed account with no positions (still detailed)", () => {
    const [line] = buildDetailedLines({ 5: [] }, new Set([5]));
    expect(line.holdings).toEqual([]);
    expect(line.value).toBe(0);
  });

  it("includes a detailed account even when its basket key is absent", () => {
    // Dispatch is on account.kind: account 9 is detailed but the user never
    // touched it, so `holdings[9]` is undefined — it must still yield a line
    // (with an empty holdings array) so the save records it as detailed.
    const [line] = buildDetailedLines({}, new Set([9]));
    expect(line.account_id).toBe(9);
    expect(line.holdings).toEqual([]);
  });

  it("skips a fully-blank row but throws on a partially-filled one", () => {
    const blank = draft({ symbol: "", quantity: "", unit_price: "" });
    const okRow = draft({ symbol: "BTC", quantity: "1", unit_price: "100", asset_type: "crypto" });
    const built = buildDetailedLines({ 5: [blank, okRow] }, new Set([5]));
    expect(built[0].holdings).toHaveLength(1);
    expect(built[0].holdings![0].symbol).toBe("BTC");
    // A symbol with no quantity is a partial row → typed error, no silent drop.
    const partial = draft({ symbol: "ETH", quantity: "", unit_price: "" });
    expect(() => buildDetailedLines({ 5: [partial] }, new Set([5]))).toThrow(
      BalanceServiceError
    );
  });

  it("carries asset_type + currency + book_cost through to the holding input", () => {
    const [line] = buildDetailedLines(
      {
        5: [
          draft({
            symbol: "btc",
            asset_type: "crypto",
            currency: "CAD",
            quantity: "0.5",
            unit_price: "1000",
            book_cost: "400",
          }),
        ],
      },
      new Set([5])
    );
    const h = line.holdings![0];
    expect(h.symbol).toBe("btc"); // trimmed only here; service normalizes case
    expect(h.asset_type).toBe("crypto");
    expect(h.currency).toBe("CAD");
    expect(h.book_cost).toBe(400);
    expect(h.value).toBe(500);
  });
});

describe("holdingsFromCsvRows — CSV rows → drafts (#245)", () => {
  const withPrice: HoldingColumnMapping = {
    symbol: 0,
    quantity: 1,
    unit_price: 2,
    book_cost: 3,
  };

  it("maps rows to drafts, normalizing the symbol and parsing FR numbers", () => {
    const drafts = holdingsFromCsvRows(
      [
        ["aapl", "10", "150,25", "1 200,00"],
        ["msft", "5", "300.50", "1400"],
      ],
      withPrice
    );
    expect(drafts).toHaveLength(2);
    expect(drafts[0].symbol).toBe("AAPL"); // normalized UPPER/TRIM
    expect(drafts[0].quantity).toBe("10");
    expect(drafts[0].unit_price).toBe("150.25"); // FR comma decimal
    expect(drafts[0].book_cost).toBe("1200"); // FR thousands space stripped
    expect(drafts[1].symbol).toBe("MSFT");
    expect(drafts[1].unit_price).toBe("300.5");
  });

  it("leaves unit_price empty when there is no price column (flexible price)", () => {
    const noPrice: HoldingColumnMapping = {
      symbol: 0,
      quantity: 1,
      unit_price: null,
      book_cost: null,
    };
    const [d] = holdingsFromCsvRows([["AAPL", "10"]], noPrice);
    expect(d.symbol).toBe("AAPL");
    expect(d.quantity).toBe("10");
    expect(d.unit_price).toBe(""); // → validated at save (fetch/type later)
    expect(d.book_cost).toBe("");
  });

  it("merges duplicate symbols: SUM quantity + book_cost, keep first price", () => {
    const drafts = holdingsFromCsvRows(
      [
        ["AAPL", "6", "150.00", "700"],
        ["MSFT", "5", "300.00", "1400"],
        ["AAPL", "4", "151.00", "500"], // second AAPL lot
      ],
      withPrice
    );
    // Two distinct securities → two drafts, no UNIQUE(symbol) violation.
    expect(drafts).toHaveLength(2);
    const aapl = drafts.find((d) => d.symbol === "AAPL")!;
    expect(aapl.quantity).toBe("10"); // 6 + 4
    expect(aapl.book_cost).toBe("1200"); // 700 + 500
    expect(aapl.unit_price).toBe("150"); // FIRST non-empty price wins
  });

  it("skips rows with a blank symbol and defaults asset_type", () => {
    const drafts = holdingsFromCsvRows(
      [
        ["", "10", "1", "1"],
        ["  ", "10", "1", "1"],
        ["BTC", "0.5", "1000", "400"],
      ],
      withPrice,
      { defaultAssetType: "crypto" }
    );
    expect(drafts).toHaveLength(1);
    expect(drafts[0].symbol).toBe("BTC");
    expect(drafts[0].asset_type).toBe("crypto");
  });
});

describe("IMPORT_HOLDINGS reducer — bulk merge into a basket (#245)", () => {
  const mapping: HoldingColumnMapping = {
    symbol: 0,
    quantity: 1,
    unit_price: 2,
    book_cost: 3,
  };

  it("appends imported drafts into an empty basket", () => {
    const drafts = holdingsFromCsvRows(
      [
        ["AAPL", "10", "150", "1200"],
        ["MSFT", "5", "300", "1400"],
      ],
      mapping
    );
    const s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: drafts },
    });
    expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "MSFT"]);
    expect(s.isDirty).toBe(true);
  });

  it("updates an existing symbol in place (keeps its rowId) and appends new ones", () => {
    const first = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
    let s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: first },
    });
    const aaplRowId = s.holdings[5][0].rowId;

    const second = holdingsFromCsvRows(
      [
        ["AAPL", "12", "160", "1300"],
        ["GOOG", "2", "140", "260"],
      ],
      mapping
    );
    s = reducer(s, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: second },
    });
    // No duplicate AAPL row — merged in place with a stable rowId.
    expect(s.holdings[5].filter((h) => h.symbol === "AAPL")).toHaveLength(1);
    const aapl = s.holdings[5].find((h) => h.symbol === "AAPL")!;
    expect(aapl.rowId).toBe(aaplRowId);
    expect(aapl.quantity).toBe("12"); // import wins for quantity
    expect(aapl.unit_price).toBe("160");
    expect(s.holdings[5].map((h) => h.symbol)).toEqual(["AAPL", "GOOG"]);
  });

  it("an import WITHOUT price does not wipe a previously-set price", () => {
    const priced = holdingsFromCsvRows([["AAPL", "10", "150", "1200"]], mapping);
    let s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: priced },
    });
    const noPriceMapping: HoldingColumnMapping = {
      symbol: 0,
      quantity: 1,
      unit_price: null,
      book_cost: null,
    };
    const repriced = holdingsFromCsvRows([["AAPL", "20"]], noPriceMapping);
    s = reducer(s, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: repriced },
    });
    const aapl = s.holdings[5][0];
    expect(aapl.quantity).toBe("20"); // qty updated
    expect(aapl.unit_price).toBe("150"); // price preserved
  });

  it("produces no duplicate symbols when the batch itself repeats a symbol", () => {
    // holdingsFromCsvRows already merges within the batch; the reducer is a
    // second guard. Even if fed pre-merged drafts, the basket stays unique.
    const drafts = holdingsFromCsvRows(
      [
        ["AAPL", "3", "150", "450"],
        ["AAPL", "7", "150", "1050"],
      ],
      mapping
    );
    const s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: drafts },
    });
    expect(s.holdings[5]).toHaveLength(1);
    expect(s.holdings[5][0].quantity).toBe("10");
  });
});

describe("IMPORT_HOLDINGS → buildDetailedLines end-to-end (#245)", () => {
  const mapping: HoldingColumnMapping = {
    symbol: 0,
    quantity: 1,
    unit_price: 2,
    book_cost: 3,
  };

  it("imported priced drafts flow through buildDetailedLines to holdings", () => {
    const drafts = holdingsFromCsvRows(
      [
        ["AAPL", "10", "150.25", "1200"],
        ["MSFT", "5", "300.50", "1400"],
      ],
      mapping
    );
    const s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: drafts },
    });
    const [line] = buildDetailedLines(s.holdings, new Set([5]));
    expect(line.holdings).toHaveLength(2);
    expect(line.holdings![0].symbol).toBe("AAPL");
    expect(line.holdings![0].unit_price).toBe(150.25);
    expect(line.holdings![0].book_cost).toBe(1200);
    // 10 * 150.25 + 5 * 300.50 = 1502.50 + 1502.50 = 3005.
    expect(line.value).toBe(3005);
  });

  it("imported drafts WITHOUT price fail save validation until fetched (GOTCHA)", () => {
    const noPriceMapping: HoldingColumnMapping = {
      symbol: 0,
      quantity: 1,
      unit_price: null,
      book_cost: null,
    };
    const drafts = holdingsFromCsvRows([["AAPL", "10"]], noPriceMapping);
    const s = reducer(base, {
      type: "IMPORT_HOLDINGS",
      payload: { accountId: 5, holdings: drafts },
    });
    let code: string | null = null;
    try {
      buildDetailedLines(s.holdings, new Set([5]));
    } catch (e) {
      code = (e as BalanceServiceError).code;
    }
    expect(code).toBe("snapshot_priced_unit_price_required");
  });
});

describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
  it("routes a detailed account through holdings even if its category is simple", () => {
    // Regression target: the account's OWN kind decides the path. Account 5 is
    // detailed; nothing about a 'simple' category should pull it into the
    // scalar (buildSimpleLines) path.
    const detailedIds = new Set([5]);
    const values = { 5: "12345" }; // a stray scalar value must be ignored
    const holdings = {
      5: [
        {
          ...makeEmptyHolding("stock"),
          symbol: "AAPL",
          quantity: "2",
          unit_price: "50",
        },
      ],
    };
    expect(buildSimpleLines(values, detailedIds)).toEqual([]); // not scalar
    const [line] = buildDetailedLines(holdings, detailedIds);
    expect(line.account_id).toBe(5);
    expect(line.value).toBe(100);
    expect(line.holdings![0].symbol).toBe("AAPL");
  });
});
